From 19ce0af67815a587bd0d45d23b993ef2d3669141 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:32:11 +0000 Subject: [PATCH 1/3] feat(reporting): contextual report panel and per-user hidden card feed exclusion --- .../cardpicker/migrations/0106_hiddencard.py | 32 +++++ MPCAutofill/cardpicker/models.py | 25 ++++ MPCAutofill/cardpicker/question_feed.py | 119 +++++++++++++++--- MPCAutofill/cardpicker/question_feed_pools.py | 30 ++++- MPCAutofill/cardpicker/schema_types.py | 6 +- MPCAutofill/cardpicker/tests/factories.py | 8 ++ .../cardpicker/tests/test_moderation_views.py | 56 ++++++++- .../cardpicker/tests/test_question_feed.py | 36 ++++++ .../tests/test_question_feed_pools.py | 39 ++++++ MPCAutofill/cardpicker/views.py | 11 ++ docs/features/moderation.md | 32 +++++ frontend/src/common/constants.ts | 5 + frontend/src/common/cookies.test.ts | 46 +++++++ frontend/src/common/cookies.ts | 51 ++++++++ frontend/src/common/schema_types.ts | 2 + frontend/src/features/modals/Modals.tsx | 19 +-- .../reporting/ReportCardPanel.test.tsx | 99 ++++++++++++++- .../features/reporting/ReportCardPanel.tsx | 21 +++- frontend/src/features/ui/Layout.tsx | 13 ++ frontend/src/store/api.ts | 5 +- frontend/src/store/listenerMiddleware.ts | 22 ++++ frontend/src/store/slices/hiddenCardsSlice.ts | 66 ++++++++++ frontend/src/store/store.ts | 2 + .../schemas/endpoints/ReportCardRequest.json | 3 +- 24 files changed, 716 insertions(+), 32 deletions(-) create mode 100644 MPCAutofill/cardpicker/migrations/0106_hiddencard.py create mode 100644 frontend/src/store/slices/hiddenCardsSlice.ts diff --git a/MPCAutofill/cardpicker/migrations/0106_hiddencard.py b/MPCAutofill/cardpicker/migrations/0106_hiddencard.py new file mode 100644 index 000000000..0cdaedd1b --- /dev/null +++ b/MPCAutofill/cardpicker/migrations/0106_hiddencard.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.30 on 2026-08-07 00:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cardpicker", "0105_question_feed_pools_schedule"), + ] + + operations = [ + migrations.CreateModel( + name="HiddenCard", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("anonymous_id", models.CharField(max_length=40)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "card", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="hidden_by", to="cardpicker.card" + ), + ), + ], + ), + migrations.AddConstraint( + model_name="hiddencard", + constraint=models.UniqueConstraint(fields=("card", "anonymous_id"), name="hiddencard_unique_hide"), + ), + ] diff --git a/MPCAutofill/cardpicker/models.py b/MPCAutofill/cardpicker/models.py index 176ef3fd8..4a17299cc 100755 --- a/MPCAutofill/cardpicker/models.py +++ b/MPCAutofill/cardpicker/models.py @@ -1652,6 +1652,31 @@ def __str__(self) -> str: return f"{self.card.name} -> {self.reason} ({self.anonymous_id})" +class HiddenCard(models.Model): + """ + A durable per-anonymous_id record that `card` should be excluded from that identity's own + future question-feed items (see docs/features/moderation.md's hidden-card section). Written + by `views.post_report_card` when the report carries `hide=True` (`ReportCardRequest.hide`, + additive to the existing report payload) - always alongside a `CardReport` row, in the same + transaction, never in place of one. Scoped to the client-generated anonymous_id only, same + as every other vote/report table here - no account linkage yet (see that doc section for + what this deliberately does not do). `get_or_create`d at the write site, so a repeat report + with `hide=True` for the same (card, anonymous_id) is a no-op rather than an IntegrityError. + """ + + card = models.ForeignKey(to=Card, on_delete=models.CASCADE, related_name="hidden_by") + anonymous_id = models.CharField(max_length=40) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["card", "anonymous_id"], name="hiddencard_unique_hide"), + ] + + def __str__(self) -> str: + return f"{self.card.name} hidden for {self.anonymous_id}" + + class TagSuggestionStatus(models.TextChoices): PENDING = "pending", "Pending" AUTO_ACCEPTED = "auto_accepted", "Auto-accepted" diff --git a/MPCAutofill/cardpicker/question_feed.py b/MPCAutofill/cardpicker/question_feed.py index e8e52b5f3..f0930866f 100644 --- a/MPCAutofill/cardpicker/question_feed.py +++ b/MPCAutofill/cardpicker/question_feed.py @@ -99,6 +99,7 @@ CardPrintingTag, CardScanLog, CardTagVote, + HiddenCard, PrintingTagStatus, QuestionFeedServedLog, QuestionFeedServedPool, @@ -373,6 +374,34 @@ def _not_official_art_card_ids() -> set[int]: return identity_group_expanded_card_ids(human_backed_card_ids) +def _voter_hidden_card_ids(anonymous_id: str) -> set[int]: + """ + Every card this anonymous_id has hidden for themselves via a `hide=True` card report + (`HiddenCard`, written by `views.post_report_card` in the same transaction as the report + - see docs/features/moderation.md's hidden-card section). The exclusion set every feed + candidate below is filtered against, so a card a voter asked to stop seeing never comes + back in that identity's own future feed items, across every question kind - printing, + artist and tag questions all key on the same card, so one card-level exclusion covers + all three (unlike the answered-card exclusions, none of which is question-kind-agnostic). + + Widened to each card's full md5 identity group (`identity_group_expanded_card_ids`), same + as `_voter_answered_printing_card_ids`/`_voter_answered_artist_card_ids`: this module's + identity-grouping premise is that a byte-identical image file is ONE identification + target (issue #473), and a voter who hid "this image" hid the group, not just the one + member the report modal happened to be showing - otherwise the feed would immediately + re-serve the same artwork under a sibling identifier and the hide would look broken. + Degenerates exactly to the card-scoped behavior while no `Card.md5_checksum` rows exist + (the pre-PR-1 state, same as every other widened exclusion here). + + COMPUTED ONCE PER FEED REQUEST, in `get_next_question_feed_item`, and passed down to + every branch that needs it (the 2026-07-25 gate on PR #482, condition f1 convention); + the tiers keep an optional parameter so a direct caller - a test, a shell - can still + ask for one tier by `anonymous_id` alone. + """ + hidden_card_ids = HiddenCard.objects.filter(anonymous_id=anonymous_id).values_list("card_id", flat=True) + return identity_group_expanded_card_ids(hidden_card_ids) + + def is_likely_resolve_printing(card: Card) -> bool: """ True when ONE hypothetical additional agreeing human vote (`VoteSource.USER` weight) added @@ -424,13 +453,17 @@ def is_likely_resolve_printing(card: Card) -> bool: return winning_key == leading_key -def _likely_resolve_printing_card(anonymous_id: str, answered_card_ids: Optional[set[int]] = None) -> Optional[Card]: +def _likely_resolve_printing_card( + anonymous_id: str, answered_card_ids: Optional[set[int]] = None, hidden_card_ids: Optional[set[int]] = None +) -> Optional[Card]: """ First UNRESOLVED printing card (in `date_created` order, same scan convention tier 1 uses) that both carries at least one existing `CardPrintingTag` row and passes `is_likely_resolve_printing` - the >=51% mix-composition policy's own supply pool (see this module's docstring for the ratio policy this feeds, and `get_next_question_feed_item` for - where it's consulted). + where it's consulted). `hidden_card_ids` (this voter's `_voter_hidden_card_ids` set, or a + direct caller's own) is excluded like `answered_card_ids` - a card this voter hid for + themselves must not resurface through this pool either. Cost/approach (compute-per-serve, no caching layer - stated per this change's own spec): pre-filters to `printing_tags__isnull=False` (97,212 of 218,345 cards at the 2026-07-24 data @@ -457,9 +490,12 @@ def _likely_resolve_printing_card(anonymous_id: str, answered_card_ids: Optional """ if answered_card_ids is None: answered_card_ids = _voter_answered_printing_card_ids(anonymous_id) + if hidden_card_ids is None: + hidden_card_ids = _voter_hidden_card_ids(anonymous_id) candidates = ( Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED, printing_tags__isnull=False) .exclude(pk__in=answered_card_ids) + .exclude(pk__in=hidden_card_ids) .distinct() .order_by("date_created") ) @@ -482,10 +518,12 @@ def _likely_resolve_item(card: Card) -> QuestionFeedItem: def _tier_1_confirm_suggestion( - anonymous_id: str, answered_card_ids: Optional[set[int]] = None + anonymous_id: str, answered_card_ids: Optional[set[int]] = None, hidden_card_ids: Optional[set[int]] = None ) -> Optional[QuestionFeedItem]: if answered_card_ids is None: answered_card_ids = _voter_answered_printing_card_ids(anonymous_id) + if hidden_card_ids is None: + hidden_card_ids = _voter_hidden_card_ids(anonymous_id) cards = ( Card.objects.filter( printing_tag_status=PrintingTagStatus.UNRESOLVED, @@ -493,6 +531,7 @@ def _tier_1_confirm_suggestion( ) .exclude(printing_tags__source__in=[VoteSource.USER, VoteSource.ADMIN, VoteSource.FEDERATED]) .exclude(pk__in=answered_card_ids) + .exclude(pk__in=hidden_card_ids) .distinct() .order_by("date_created") ) @@ -511,6 +550,7 @@ def _tier_2_contested( not_official_art_card_ids: Optional[set[int]] = None, contested_card_ids: Optional[list[int]] = None, contested_artist_card_ids: Optional[list[int]] = None, + hidden_card_ids: Optional[set[int]] = None, ) -> Optional[tuple[QuestionFeedItem, str]]: if answered_card_ids is None: answered_card_ids = _voter_answered_printing_card_ids(anonymous_id) @@ -524,10 +564,13 @@ def _tier_2_contested( contested_card_ids = get_contested_card_ids() if contested_artist_card_ids is None: contested_artist_card_ids = get_contested_artist_card_ids() + if hidden_card_ids is None: + hidden_card_ids = _voter_hidden_card_ids(anonymous_id) printing_card = ( Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED, pk__in=contested_card_ids) .exclude(pk__in=answered_card_ids) + .exclude(pk__in=hidden_card_ids) .order_by("-date_created") .first() ) @@ -538,6 +581,7 @@ def _tier_2_contested( Card.objects.filter(artist_vote_status=ArtistVoteStatus.CONTESTED, pk__in=contested_artist_card_ids) .exclude(pk__in=answered_artist_card_ids) .exclude(pk__in=not_official_art_card_ids) + .exclude(pk__in=hidden_card_ids) .order_by("-date_created") .first() ) @@ -554,6 +598,8 @@ def _tier_2_contested( # moment this voter touches any one tag on it. if card_id in answered_tag_card_ids_by_tag.get(tag_name, set()): continue + if card_id in hidden_card_ids: + continue card = Card.objects.get(pk=card_id) status = card.tag_vote_statuses.get(tag_name) if status == TagVoteStatus.CONTESTED: @@ -580,6 +626,7 @@ def _tier_4_fresh( answered_card_ids: Optional[set[int]] = None, not_official_art_card_ids: Optional[set[int]] = None, contested_card_ids: Optional[list[int]] = None, + hidden_card_ids: Optional[set[int]] = None, ) -> Optional[tuple[QuestionFeedItem, str]]: # named "tier 4" (not renumbered to 3) even though moderation's former tier 3 was removed # (see module docstring) - keeps this name stable against every docstring/test/comment @@ -608,10 +655,13 @@ def _tier_4_fresh( not_official_art_card_ids = _not_official_art_card_ids() if contested_card_ids is None: contested_card_ids = get_contested_card_ids() + if hidden_card_ids is None: + hidden_card_ids = _voter_hidden_card_ids(anonymous_id) printing_card = ( Card.objects.filter(printing_tag_status=PrintingTagStatus.UNRESOLVED) .exclude(pk__in=contested_card_ids) .exclude(pk__in=answered_card_ids) + .exclude(pk__in=hidden_card_ids) .annotate(vote_count=Count("printing_tags", distinct=True)) .annotate(origin_reason=_latest_stage_d_origin_reason_subquery()) .annotate( @@ -636,6 +686,7 @@ def _tier_4_fresh( Card.objects.filter(artist_vote_status=ArtistVoteStatus.UNRESOLVED) .exclude(artist_votes__anonymous_id=anonymous_id) .exclude(pk__in=not_official_art_card_ids) + .exclude(pk__in=hidden_card_ids) .order_by("-date_created") .first() ) @@ -646,6 +697,8 @@ def _tier_4_fresh( # see tier 2's identical comment above - scoped to (card, tag, anonymous_id) if CardTagVote.objects.filter(card_id=card_id, tag__name=tag_name, anonymous_id=anonymous_id).exists(): continue + if card_id in hidden_card_ids: + continue card = Card.objects.get(pk=card_id) status = card.tag_vote_statuses.get(tag_name) if status == TagVoteStatus.UNRESOLVED: @@ -688,14 +741,22 @@ def _pool_contested_result( answered_artist_card_ids: set[int], answered_tag_card_ids_by_tag: dict[str, set[int]], not_official_art_card_ids: set[int], + hidden_card_ids: Optional[set[int]] = None, ) -> Optional[tuple[QuestionFeedItem, str]]: """Pool-backed fast path for `_tier_2_contested`: converts a drawn `(kind, card, tag_name, reason)` into the same `(QuestionFeedItem, reason)` shape that function returns, using its own item-builders (`_identify_printing_item`/`_artist_item`/`_tag_item`) so a pool-served item is byte-for-byte the same shape a live-served one would be. `None` on a pool miss - the - caller falls back to `_tier_2_contested` itself.""" + caller falls back to `_tier_2_contested` itself. `hidden_card_ids` is threaded to + `draw_contested_entry` unchanged (this voter's `_voter_hidden_card_ids` set); `None` means + no hidden exclusion, which only a direct caller ever exercises - `get_next_question_feed_item` + always passes the computed set.""" drawn = question_feed_pools.draw_contested_entry( - answered_card_ids, answered_artist_card_ids, answered_tag_card_ids_by_tag, not_official_art_card_ids + answered_card_ids, + answered_artist_card_ids, + answered_tag_card_ids_by_tag, + not_official_art_card_ids, + hidden_card_ids, ) if drawn is None: return None @@ -713,10 +774,13 @@ def _pool_cold_result( answered_card_ids: set[int], not_official_art_card_ids: set[int], contested_card_ids: list[int], + hidden_card_ids: Optional[set[int]] = None, ) -> Optional[tuple[QuestionFeedItem, str]]: - """The `_tier_4_fresh` analogue of `_pool_contested_result` above.""" + """The `_tier_4_fresh` analogue of `_pool_contested_result` above. `hidden_card_ids` is + threaded to `draw_cold_entry` unchanged; `None` means no hidden exclusion (see that + function's own docstring for the same direct-caller-only caveat).""" drawn = question_feed_pools.draw_cold_entry( - anonymous_id, answered_card_ids, not_official_art_card_ids, contested_card_ids + anonymous_id, answered_card_ids, not_official_art_card_ids, contested_card_ids, hidden_card_ids ) if drawn is None: return None @@ -753,7 +817,12 @@ def get_next_question_feed_item( docstrings for why this is scoped to that tier only), and `_not_official_art_card_ids` (the phase-C routing signal: a card a human has declared not-official-art via `reason_tags. NOT_OFFICIAL_ART_REASON_TAGS` stops being served as an artist-shaped question, in both - `_tier_2_contested` and `_tier_4_fresh` - printing questions are unaffected). + `_tier_2_contested` and `_tier_4_fresh` - printing questions are unaffected). One more + request-scoped exclusion rides the same convention (issue #714): `_voter_hidden_card_ids` + (a card this voter hid for themselves via a `hide=True` card report - see that function's + docstring), threaded to EVERY branch below, since unlike the answered-card exclusions it is + question-kind-agnostic: a hidden card must not resurface as a printing, artist OR tag + question. `contested_card_ids` is an optional pre-resolved value (issue #713 part 2, extending PR #729's "compute once, thread as an optional parameter" convention across the view boundary @@ -767,21 +836,31 @@ def get_next_question_feed_item( answered_artist_card_ids = _voter_answered_artist_card_ids(anonymous_id) answered_tag_card_ids_by_tag = _voter_answered_tag_card_ids_by_tag(anonymous_id) not_official_art_card_ids = _not_official_art_card_ids() + # This voter's own hidden-card exclusion (issue #714 - `HiddenCard` rows written by + # `views.post_report_card` when a report carries `hide=True`): computed ONCE here and + # threaded to every branch below, same convention as the other request-scoped exclusions, + # so a card this identity hid for themselves never comes back in their feed, whichever + # tier would otherwise have served it. + hidden_card_ids = _voter_hidden_card_ids(anonymous_id) if _served_mix_ratio(anonymous_id) < settings.QUESTION_FEED_LIKELY_RESOLVE_MIX_RATIO: - likely_resolve_card = question_feed_pools.draw_resolution_imminent_card(answered_card_ids) + likely_resolve_card = question_feed_pools.draw_resolution_imminent_card( + answered_card_ids, hidden_card_ids=hidden_card_ids + ) if likely_resolve_card is None: - likely_resolve_card = _likely_resolve_printing_card(anonymous_id, answered_card_ids) + likely_resolve_card = _likely_resolve_printing_card( + anonymous_id, answered_card_ids, hidden_card_ids=hidden_card_ids + ) if likely_resolve_card is not None: item = _likely_resolve_item(likely_resolve_card) return _log_served( anonymous_id, item, QuestionFeedServedPool.LIKELY_RESOLVE, "printing_one_vote_from_resolving" ) - tier_1_card = question_feed_pools.draw_confirm_card(answered_card_ids) + tier_1_card = question_feed_pools.draw_confirm_card(answered_card_ids, hidden_card_ids=hidden_card_ids) tier_1_item = _confirm_suggestion_item(tier_1_card) if tier_1_card is not None else None if tier_1_item is None: - tier_1_item = _tier_1_confirm_suggestion(anonymous_id, answered_card_ids) + tier_1_item = _tier_1_confirm_suggestion(anonymous_id, answered_card_ids, hidden_card_ids=hidden_card_ids) if tier_1_item is not None: return _log_served(anonymous_id, tier_1_item, QuestionFeedServedPool.REMAINDER, "tier_1_confirm_suggestion") @@ -795,7 +874,11 @@ def get_next_question_feed_item( contested_artist_card_ids = get_contested_artist_card_ids() tier_2_result = _pool_contested_result( - answered_card_ids, answered_artist_card_ids, answered_tag_card_ids_by_tag, not_official_art_card_ids + answered_card_ids, + answered_artist_card_ids, + answered_tag_card_ids_by_tag, + not_official_art_card_ids, + hidden_card_ids=hidden_card_ids, ) if tier_2_result is None: tier_2_result = _tier_2_contested( @@ -806,18 +889,26 @@ def get_next_question_feed_item( not_official_art_card_ids=not_official_art_card_ids, contested_card_ids=contested_card_ids, contested_artist_card_ids=contested_artist_card_ids, + hidden_card_ids=hidden_card_ids, ) if tier_2_result is not None: tier_2_item, tier_2_reason = tier_2_result return _log_served(anonymous_id, tier_2_item, QuestionFeedServedPool.REMAINDER, tier_2_reason) - tier_4_result = _pool_cold_result(anonymous_id, answered_card_ids, not_official_art_card_ids, contested_card_ids) + tier_4_result = _pool_cold_result( + anonymous_id, + answered_card_ids, + not_official_art_card_ids, + contested_card_ids, + hidden_card_ids=hidden_card_ids, + ) if tier_4_result is None: tier_4_result = _tier_4_fresh( anonymous_id, answered_card_ids, not_official_art_card_ids=not_official_art_card_ids, contested_card_ids=contested_card_ids, + hidden_card_ids=hidden_card_ids, ) if tier_4_result is not None: tier_4_item, tier_4_reason = tier_4_result diff --git a/MPCAutofill/cardpicker/question_feed_pools.py b/MPCAutofill/cardpicker/question_feed_pools.py index 1c6467e35..0660202c9 100644 --- a/MPCAutofill/cardpicker/question_feed_pools.py +++ b/MPCAutofill/cardpicker/question_feed_pools.py @@ -362,26 +362,34 @@ def _fetch_unresolved_printing_card(card_id: int) -> Optional[Card]: return Card.objects.filter(pk=card_id, printing_tag_status=PrintingTagStatus.UNRESOLVED).first() -def draw_resolution_imminent_card(answered_card_ids: set[int]) -> Optional[Card]: +def draw_resolution_imminent_card( + answered_card_ids: set[int], hidden_card_ids: Optional[set[int]] = None +) -> Optional[Card]: entries = _get_cached_pool(LANE_RESOLUTION_IMMINENT) if not entries: return None + hidden_card_ids = hidden_card_ids or set() for entry in _iter_from_random_offset(entries): if entry.card_id in answered_card_ids: continue + if entry.card_id in hidden_card_ids: + continue card = _fetch_unresolved_printing_card(entry.card_id) if card is not None: return card return None -def draw_confirm_card(answered_card_ids: set[int]) -> Optional[Card]: +def draw_confirm_card(answered_card_ids: set[int], hidden_card_ids: Optional[set[int]] = None) -> Optional[Card]: entries = _get_cached_pool(LANE_CONFIRM) if not entries: return None + hidden_card_ids = hidden_card_ids or set() for entry in _iter_from_random_offset(entries): if entry.card_id in answered_card_ids: continue + if entry.card_id in hidden_card_ids: + continue card = _fetch_unresolved_printing_card(entry.card_id) if card is not None: return card @@ -393,16 +401,24 @@ def draw_contested_entry( answered_artist_card_ids: set[int], answered_tag_card_ids_by_tag: dict[str, set[int]], not_official_art_card_ids: set[int], + hidden_card_ids: Optional[set[int]] = None, ) -> Optional[tuple[str, Card, Optional[str], Optional[str]]]: """Returns `(kind, card, tag_name, reason)` for the first unexcluded, unstale entry found from a random offset, or `None`. Exclusion sets match `_tier_2_contested`'s own exactly - all three (`answered_card_ids`/`answered_artist_card_ids`/`answered_tag_card_ids_by_tag`) are already the md5-widened, per-request-memoised sets `get_next_question_feed_item` computes - once and threads through, same as the live tier.""" + once and threads through, same as the live tier. `hidden_card_ids` (this voter's + `question_feed._voter_hidden_card_ids` set, `None` for a direct caller meaning no hidden + exclusion) applies to all three kinds at once - the live tier excludes a hidden card from + its printing AND artist querysets and its tag loop, and this reproduces that card-level + exclusion here rather than per-kind.""" entries = _get_cached_pool(LANE_CONTESTED) if not entries: return None + hidden_card_ids = hidden_card_ids or set() for entry in _iter_from_random_offset(entries): + if entry.card_id in hidden_card_ids: + continue if entry.kind == KIND_PRINTING: if entry.card_id in answered_card_ids: continue @@ -432,6 +448,7 @@ def draw_cold_entry( answered_card_ids: set[int], not_official_art_card_ids: set[int], contested_card_ids: list[int], + hidden_card_ids: Optional[set[int]] = None, ) -> Optional[tuple[str, Card, Optional[str], Optional[str]]]: """The cold-lane analogue of `draw_contested_entry`. `contested_card_ids` is re-checked in-memory (a card can have gone from fresh to contested since this pool's last warm) - @@ -441,12 +458,17 @@ def draw_cold_entry( itself keeps its own pre-existing unwidened form for both (see that function's own docstring for why: the widened convention is scoped to `_tier_2_contested` only), so reproducing it here means one extra indexed query per artist/tag candidate scanned rather than a second, - possibly-diverging exclusion rule.""" + possibly-diverging exclusion rule. `hidden_card_ids` (`question_feed._voter_hidden_card_ids`, + `None` for a direct caller meaning no hidden exclusion) is applied card-level, same as the + contested lane above.""" entries = _get_cached_pool(LANE_COLD) if not entries: return None + hidden_card_ids = hidden_card_ids or set() contested_card_id_set = set(contested_card_ids) for entry in _iter_from_random_offset(entries): + if entry.card_id in hidden_card_ids: + continue if entry.kind == KIND_PRINTING: if entry.card_id in answered_card_ids or entry.card_id in contested_card_id_set: continue diff --git a/MPCAutofill/cardpicker/schema_types.py b/MPCAutofill/cardpicker/schema_types.py index c7f96b369..774696487 100644 --- a/MPCAutofill/cardpicker/schema_types.py +++ b/MPCAutofill/cardpicker/schema_types.py @@ -2143,6 +2143,7 @@ class ReportCardRequest(BaseModel): anonymousId: str identifier: str reason: Reason + hide: Optional[bool] = None text: Optional[str] = None @staticmethod @@ -2151,14 +2152,17 @@ def from_dict(obj: Any) -> "ReportCardRequest": anonymousId = from_str(obj.get("anonymousId")) identifier = from_str(obj.get("identifier")) reason = Reason(obj.get("reason")) + hide = from_union([from_bool, from_none], obj.get("hide")) text = from_union([from_str, from_none], obj.get("text")) - return ReportCardRequest(anonymousId, identifier, reason, text) + return ReportCardRequest(anonymousId, identifier, reason, hide, text) def to_dict(self) -> dict: result: dict = {} result["anonymousId"] = from_str(self.anonymousId) result["identifier"] = from_str(self.identifier) result["reason"] = to_enum(Reason, self.reason) + if self.hide is not None: + result["hide"] = from_union([from_bool, from_none], self.hide) if self.text is not None: result["text"] = from_union([from_str, from_none], self.text) return result diff --git a/MPCAutofill/cardpicker/tests/factories.py b/MPCAutofill/cardpicker/tests/factories.py index 26d028a2b..834236558 100644 --- a/MPCAutofill/cardpicker/tests/factories.py +++ b/MPCAutofill/cardpicker/tests/factories.py @@ -168,6 +168,14 @@ class Meta: text = "" +class HiddenCardFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.HiddenCard + + card = factory.SubFactory(CardFactory) + anonymous_id = factory.Sequence(lambda n: f"anonymous_{n}") + + class SavedDeckFactory(factory.django.DjangoModelFactory): class Meta: model = models.SavedDeck diff --git a/MPCAutofill/cardpicker/tests/test_moderation_views.py b/MPCAutofill/cardpicker/tests/test_moderation_views.py index f79401c9e..8ea38fac2 100644 --- a/MPCAutofill/cardpicker/tests/test_moderation_views.py +++ b/MPCAutofill/cardpicker/tests/test_moderation_views.py @@ -16,6 +16,7 @@ CardReportReason, CardTagVote, CardTypes, + HiddenCard, Source, TagModerationClass, TagVoteStatus, @@ -151,10 +152,19 @@ def autouse_django_settings(self, django_settings): pass @staticmethod - def report(client, card, reason: str, text: str | None = None, anonymous_id: str = "anon-1"): + def report( + client, + card, + reason: str, + text: str | None = None, + anonymous_id: str = "anon-1", + hide: bool | None = None, + ): body: dict = {"identifier": card.identifier, "anonymousId": anonymous_id, "reason": reason} if text is not None: body["text"] = text + if hide is not None: + body["hide"] = hide return client.post(reverse(views.post_report_card), body, content_type="application/json") def test_report_writes_audit_row(self, client): @@ -245,6 +255,50 @@ def test_rate_limit_is_per_anonymous_id(self, client, settings): assert self.report(client, card, "broken_image", anonymous_id="anon-a").status_code == 200 assert self.report(client, card, "broken_image", anonymous_id="anon-b").status_code == 200 + def test_hide_true_writes_hidden_card_in_the_same_transaction(self, client): + card = CardFactory() + response = self.report(client, card, "broken_image", hide=True) + assert response.status_code == 200 + # the report always lands, and the hide adds the exclusion alongside it - never in + # place of the report (the `HiddenCard` docstring's contract) + report = CardReport.objects.get() + assert report.card == card + hidden = HiddenCard.objects.get() + assert hidden.card == card + assert hidden.anonymous_id == "anon-1" + + def test_hide_absent_or_false_never_writes_a_hidden_card(self, client): + card = CardFactory() + self.report(client, card, "broken_image") + self.report(client, card, "broken_image", anonymous_id="anon-2", hide=False) + assert CardReport.objects.count() == 2 + assert HiddenCard.objects.count() == 0 + + def test_repeat_hide_is_a_no_op_via_get_or_create(self, client): + card = CardFactory() + self.report(client, card, "broken_image", hide=True) + self.report(client, card, "broken_image", hide=True) + assert CardReport.objects.count() == 2 + assert HiddenCard.objects.count() == 1 + + def test_hide_is_scoped_per_anonymous_id(self, client): + card = CardFactory() + self.report(client, card, "broken_image", anonymous_id="anon-a", hide=True) + # a second identity reporting the same card without hide must not inherit the exclusion + self.report(client, card, "broken_image", anonymous_id="anon-b") + hidden = HiddenCard.objects.get() + assert hidden.anonymous_id == "anon-a" + + def test_rate_limited_report_with_hide_writes_nothing(self, client, settings): + settings.CARD_REPORT_RATE = "1/d" + card = CardFactory() + assert self.report(client, card, "broken_image", anonymous_id="anon-rate").status_code == 200 + assert self.report(client, card, "nsfw", anonymous_id="anon-rate", hide=True).status_code == 429 + # the 429 path returns before the transaction, so neither row exists for the hidden + # report - a hide can never outlive the report it travels with + assert CardReport.objects.count() == 1 + assert HiddenCard.objects.count() == 0 + class TestRejectUntrustedOrigin: @pytest.fixture(autouse=True) diff --git a/MPCAutofill/cardpicker/tests/test_question_feed.py b/MPCAutofill/cardpicker/tests/test_question_feed.py index 88f67a91f..fa9c5cd39 100644 --- a/MPCAutofill/cardpicker/tests/test_question_feed.py +++ b/MPCAutofill/cardpicker/tests/test_question_feed.py @@ -17,6 +17,7 @@ ArtistVoteStatus, CardPrintingTag, CardScanLog, + HiddenCard, PrintingTagStatus, QuestionFeedServedLog, QuestionFeedServedPool, @@ -244,6 +245,41 @@ def test_own_vote_exclusion_is_scoped_to_the_specific_tag_not_the_whole_card(sel assert item.card.identifier == card.identifier assert item.tagName == tag_b.name + def test_a_hidden_card_is_excluded_from_this_voters_feed(self, db): + """Issue #714: a card this voter hid for themselves (`HiddenCard`, written by + `views.post_report_card` when a report carries `hide=True`) must never come back in + their own feed items, whichever tier would otherwise have served it.""" + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + HiddenCard.objects.create(card=card, anonymous_id="anon-1") + + item = get_next_question_feed_item("anon-1") + + # the only candidate is hidden for this voter - nothing else exists, so None + assert item is None or item.card.identifier != card.identifier + + def test_a_hidden_card_is_still_served_to_other_voters(self, db): + """The exclusion is per-anonymous_id, not global: hiding a card for yourself never + hides it for anyone else - same scoping as every other vote/report table here.""" + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + HiddenCard.objects.create(card=card, anonymous_id="anon-1") + + item = get_next_question_feed_item("anon-2") + + assert item is not None + assert item.card.identifier == card.identifier + + def test_a_hidden_card_is_excluded_even_when_it_is_the_only_contested_candidate(self, db): + """Tier 2's contested printing half must respect the hidden exclusion too - a card the + voter hid must not resurface just because it became the highest-priority contested one.""" + hidden_card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + CardPrintingTagFactory(card=hidden_card, printing=CanonicalCardFactory(), source=VoteSource.USER) + CardPrintingTagFactory(card=hidden_card, printing=CanonicalCardFactory(), source=VoteSource.USER) + HiddenCard.objects.create(card=hidden_card, anonymous_id="anon-1") + + item = get_next_question_feed_item("anon-1") + + assert item is None or item.card.identifier != hidden_card.identifier + class TestContestedIdsMemoizedPerRequest: """`get_contested_card_ids`/`get_contested_artist_card_ids` are expensive (issue #726: diff --git a/MPCAutofill/cardpicker/tests/test_question_feed_pools.py b/MPCAutofill/cardpicker/tests/test_question_feed_pools.py index c22bf1695..e55a0d0c1 100644 --- a/MPCAutofill/cardpicker/tests/test_question_feed_pools.py +++ b/MPCAutofill/cardpicker/tests/test_question_feed_pools.py @@ -256,6 +256,16 @@ def test_a_resolved_card_is_excluded_at_read_time_even_though_still_pooled(self, assert draw_resolution_imminent_card(answered_card_ids=set()) is None + def test_excludes_a_card_this_voter_hid_for_themselves(self, db): + """Issue #714: the draw-time analogue of `_tier_4_fresh`/tier 1's hidden-card + exclusion - a hidden card is skipped even though it is still pooled.""" + card, _ = make_one_vote_from_resolving_card() + warm_pool_cache(LANE_RESOLUTION_IMMINENT) + + assert draw_resolution_imminent_card(answered_card_ids=set(), hidden_card_ids={card.pk}) is None + # no hidden exclusion = still served, for this voter or any other + assert draw_resolution_imminent_card(answered_card_ids=set()) is not None + class TestDrawConfirmCard: def test_returns_the_pooled_card(self, db): @@ -270,6 +280,12 @@ def test_excludes_a_card_this_voter_already_answered(self, db): warm_pool_cache(LANE_CONFIRM) assert draw_confirm_card(answered_card_ids={card.pk}) is None + def test_excludes_a_card_this_voter_hid_for_themselves(self, db): + card, _ = make_ai_suggested_card() + warm_pool_cache(LANE_CONFIRM) + + assert draw_confirm_card(answered_card_ids=set(), hidden_card_ids={card.pk}) is None + class TestDrawContestedEntry: def test_returns_a_printing_entry(self, db): @@ -312,6 +328,17 @@ def test_a_tag_entry_respects_the_per_tag_exclusion_dict(self, db): assert drawn[0] == KIND_TAG assert drawn[2] == tag.name + def test_a_hidden_card_is_excluded_from_the_printing_half(self, db): + """Issue #714: the card-level hidden exclusion applies across every contested kind - + the printing half is the common case, the artist/tag halves share the same entry-level + skip (asserted via the cold lane's equivalent test below).""" + card = CardFactory(printing_tag_status=PrintingTagStatus.UNRESOLVED) + CardPrintingTagFactory(card=card, printing=CanonicalCardFactory(), source=VoteSource.USER) + CardPrintingTagFactory(card=card, printing=CanonicalCardFactory(), source=VoteSource.USER) + warm_pool_cache(LANE_CONTESTED) + + assert draw_contested_entry(set(), set(), {}, set(), hidden_card_ids={card.pk}) is None + def test_returns_none_on_a_cache_miss(self, db): assert draw_contested_entry(set(), set(), {}, set()) is None @@ -358,6 +385,18 @@ def test_artist_half_uses_an_unwidened_per_voter_check(self, db): assert drawn is not None assert drawn[1].pk == card.pk + def test_a_hidden_card_is_excluded(self, db): + """Issue #714: the cold lane's card-level hidden exclusion, keyed on the same + entry-level skip as the contested lane.""" + card = CardFactory( + printing_tag_status=PrintingTagStatus.UNRESOLVED, artist_vote_status=ArtistVoteStatus.RESOLVED + ) + warm_pool_cache(LANE_COLD) + + assert draw_cold_entry("anon-1", set(), set(), contested_card_ids=[], hidden_card_ids={card.pk}) is None + # no hidden exclusion = still served + assert draw_cold_entry("anon-1", set(), set(), contested_card_ids=[]) is not None + def test_returns_none_on_a_cache_miss(self, db): assert draw_cold_entry("anon-1", set(), set(), contested_card_ids=[]) is None diff --git a/MPCAutofill/cardpicker/views.py b/MPCAutofill/cardpicker/views.py index debc4902e..f96fc1515 100644 --- a/MPCAutofill/cardpicker/views.py +++ b/MPCAutofill/cardpicker/views.py @@ -79,6 +79,7 @@ CardTagVote, CardTypes, DFCPair, + HiddenCard, PrintingTagStatus, SavedDeck, SavedDeckKind, @@ -1891,6 +1892,14 @@ def post_report_card(request: HttpRequest) -> HttpResponse: report still lands and the vote is skipped - same graceful degradation as the no-match reason strips. broken_image/other write the report row only. + A report may optionally carry `hide=True` (`ReportCardRequest.hide`, additive to the + existing payload) - a per-anonymous_id request that this card be excluded from that + identity's own future question-feed items (see docs/features/moderation.md's hidden-card + section). The `HiddenCard` row is written in the SAME transaction as the `CardReport`, + never in place of one: the report always lands regardless, and `hide` only ever adds the + exclusion. `get_or_create` makes a repeat `hide=True` for the same (card, anonymous_id) a + no-op rather than an IntegrityError; a `hide=False` or absent `hide` never creates one. + Rate limited per anonymous ID (IP fallback) via CARD_REPORT_RATE (default 10/day) - the vote only happens inside this view, so the one limit covers both effects. Same single-worker in-process-cache caveat as post_submit_printing_tag. @@ -1920,6 +1929,8 @@ def post_report_card(request: HttpRequest) -> HttpResponse: CardReport.objects.create( card=card, anonymous_id=req.anonymousId, user=user, reason=req.reason.value, text=req.text or "" ) + if req.hide: + HiddenCard.objects.get_or_create(card=card, anonymous_id=req.anonymousId) tag_name = REPORT_REASON_TO_TAG_NAME.get(req.reason.value) if tag_name is not None: tag = Tag.objects.filter(name=tag_name).first() diff --git a/docs/features/moderation.md b/docs/features/moderation.md index dacc77826..183162a5b 100644 --- a/docs/features/moderation.md +++ b/docs/features/moderation.md @@ -203,6 +203,38 @@ points cannot drift), in one transaction. Unseeded tag = report still lands, vote skipped. Broken image / Other are report-row-only. Rate limit: `CARD_REPORT_RATE` (default `10/d`) per anonymous_id, polite 429 in the UI. +### Per-user hide (issue #714) + +The report panel carries an optional **"Also hide this image for me"** +checkbox (the panel's own `hideForMe` state; a report with it checked sends +`hide: true` in the `ReportCardRequest` payload). On the server, +`views.post_report_card` then writes a **`HiddenCard`** row (card FK +CASCADE, `anonymous_id`, `created_at`; unique per `(card, anonymous_id)` — +`hiddencard_unique_hide`) in the **same transaction** as the `CardReport` +row, via `get_or_create` — a `HiddenCard` never exists without its +accompanying report, and `hide: false`/absent creates nothing. Hiding a card +for yourself has no moderation meaning: it is purely a per-anonymous_id +view preference, the report's reason/text still lands as a normal audit row. + +Read side: the question feed excludes this identity's hidden cards from +every tier and question kind (printing, artist, tag), widened to each +card's md5 identity group — see `question_feed._voter_hidden_card_ids` +(computed once per feed request and threaded to every branch). Pools skip +them too (`question_feed_pools.draw_*`'s `hidden_card_ids`). The exclusion +is per-anonymous_id: one visitor hiding a card never hides it for anyone +else. + +Client side: the modal/panel dispatch `hideCard(identifier)` on a +`hide=True` submission, which (a) drops the card from the current view +immediately — `Modals.tsx` gates the card-detail modal on +`selectHiddenCardIdentifiersSet` — and (b) persists a per-anonymous_id +localStorage mirror under `hiddenCardIds:` +(`cookies.ts`'s `get/setLocalStorageHiddenCardIds`, written through by the +`hideCard` listener and hydrated once at app start via +`getExistingAnonymousId`, never minting an identity just by loading). The +localStorage mirror is only session continuity for the current view; the +server-side feed filter is the durable mechanism. + ## Moderation tab `whatsthat.tsx` (`ModerationTab.tsx`) grows a **Moderation** tab alongside the diff --git a/frontend/src/common/constants.ts b/frontend/src/common/constants.ts index e937ae7b6..cc37dfee4 100644 --- a/frontend/src/common/constants.ts +++ b/frontend/src/common/constants.ts @@ -137,6 +137,11 @@ export const ManualOverridesKey = "manualOverrides"; // full rationale on why this specific, narrow, owner-approved case is exempt from this repo's // usual "no localStorage for state that should survive a clear-site-data test" rule. export const PinnedSourcesKey = "pinnedSources"; +// Per-anonymous_id set of card identifiers the visitor hid for themselves via a `hide=True` +// card report (issue #714 - see docs/features/moderation.md's hidden-card section). A prefix: +// the full storage key appends the anonymous_id, so a new anonymous identity starts with an +// empty hidden set (the server-side `HiddenCard` rows are scoped the same way). +export const HiddenCardIdsKey = "hiddenCardIds"; export const Brackets: Array = [ 18, 36, 55, 72, 90, 108, 126, 144, 162, 180, 198, 216, 234, 396, 504, 612, diff --git a/frontend/src/common/cookies.test.ts b/frontend/src/common/cookies.test.ts index 6b0814ffb..5d7ac53ca 100644 --- a/frontend/src/common/cookies.test.ts +++ b/frontend/src/common/cookies.test.ts @@ -1,5 +1,6 @@ import { AnonymousIdKey, + HiddenCardIdsKey, ManualOverridesKey, MaximumDPI, MaximumSize, @@ -7,9 +8,11 @@ import { SearchSettingsKey, } from "@/common/constants"; import { + getLocalStorageHiddenCardIds, getLocalStorageManualOverrides, getLocalStorageSearchSettings, getOrCreateAnonymousId, + setLocalStorageHiddenCardIds, setLocalStorageManualOverrides, } from "@/common/cookies"; import { defaultSettings, sourceDocuments } from "@/common/test-constants"; @@ -18,11 +21,15 @@ beforeEach(() => { window.localStorage.removeItem(SearchSettingsKey); window.localStorage.removeItem(AnonymousIdKey); window.localStorage.removeItem(ManualOverridesKey); + window.localStorage.removeItem(`${HiddenCardIdsKey}:anon-1`); + window.localStorage.removeItem(`${HiddenCardIdsKey}:anon-2`); }); afterEach(() => { window.localStorage.removeItem(SearchSettingsKey); window.localStorage.removeItem(AnonymousIdKey); window.localStorage.removeItem(ManualOverridesKey); + window.localStorage.removeItem(`${HiddenCardIdsKey}:anon-1`); + window.localStorage.removeItem(`${HiddenCardIdsKey}:anon-2`); }); //# region tests @@ -261,3 +268,42 @@ test("setLocalStorageManualOverrides persists the map for getLocalStorageManualO }); //# endregion + +//# region hidden card ids (issue #714) + +test("getLocalStorageHiddenCardIds returns an empty set when nothing is stored", () => { + expect(getLocalStorageHiddenCardIds("anon-1")).toEqual(new Set()); +}); + +test("getLocalStorageHiddenCardIds round-trips a valid stored list", () => { + setLocalStorageHiddenCardIds("anon-1", ["card-a", "card-b"]); + + expect(getLocalStorageHiddenCardIds("anon-1")).toEqual( + new Set(["card-a", "card-b"]) + ); +}); + +test("hidden card ids are scoped per anonymous id", () => { + setLocalStorageHiddenCardIds("anon-1", ["card-a"]); + + // a card hidden for one identity still shows for another - the mirror keeps the exact + // scoping of the server-side HiddenCard rows + expect(getLocalStorageHiddenCardIds("anon-2")).toEqual(new Set()); +}); + +test("getLocalStorageHiddenCardIds falls back to an empty set on invalid JSON", () => { + window.localStorage.setItem(`${HiddenCardIdsKey}:anon-1`, "not valid json{"); + + expect(getLocalStorageHiddenCardIds("anon-1")).toEqual(new Set()); +}); + +test("getLocalStorageHiddenCardIds falls back to an empty set on a non-array value", () => { + window.localStorage.setItem( + `${HiddenCardIdsKey}:anon-1`, + JSON.stringify({ card: "not-an-array" }) + ); + + expect(getLocalStorageHiddenCardIds("anon-1")).toEqual(new Set()); +}); + +//# endregion diff --git a/frontend/src/common/cookies.ts b/frontend/src/common/cookies.ts index f4163070e..7f4686bf0 100644 --- a/frontend/src/common/cookies.ts +++ b/frontend/src/common/cookies.ts @@ -9,6 +9,7 @@ import { BackendURLKey, CSRFKey, FavoritesKey, + HiddenCardIdsKey, ManualOverridesKey, PinnedSourcesKey, SearchSettingsKey, @@ -245,4 +246,54 @@ export function getOrCreateAnonymousId(): string { return generated; } +/** + * The existing anonymous id, or `null` when this browser has never generated one. Read-only + * counterpart of `getOrCreateAnonymousId` - consumers that must not mint an identity just by + * loading (e.g. hydrating per-id state at app start) use this. + */ +export function getExistingAnonymousId(): string | null { + return localStorage.getItem(AnonymousIdKey); +} + +//# endregion + +//# region hidden card ids + +/** + * The identifiers of cards this visitor hid for themselves (issue #714 - a `hide=True` card + * report; see docs/features/moderation.md's hidden-card section). Per-anonymous_id: stored + * under `HiddenCardIdsKey:` so the client-side mirror keeps the exact scoping of + * the server-side `HiddenCard` rows, and a new/cleared identity starts with an empty set. + * Persisted eagerly on write so the current session's views can drop a hidden card without a + * refetch; the durable exclusion is the server-side question-feed read filter. + */ +export function getLocalStorageHiddenCardIds(anonymousId: string): Set { + const serialised = localStorage.getItem(`${HiddenCardIdsKey}:${anonymousId}`); + if (serialised == null) { + return new Set(); + } + try { + const parsed: unknown = JSON.parse(serialised); + if ( + Array.isArray(parsed) && + parsed.every((entry) => typeof entry === "string") + ) { + return new Set(parsed); + } + return new Set(); + } catch { + return new Set(); + } +} + +export function setLocalStorageHiddenCardIds( + anonymousId: string, + hiddenIdentifiers: Iterable +): void { + localStorage.setItem( + `${HiddenCardIdsKey}:${anonymousId}`, + JSON.stringify([...hiddenIdentifiers]) + ); +} + //# endregion diff --git a/frontend/src/common/schema_types.ts b/frontend/src/common/schema_types.ts index 7a71ee775..8b573c699 100644 --- a/frontend/src/common/schema_types.ts +++ b/frontend/src/common/schema_types.ts @@ -832,6 +832,7 @@ export interface PrintingTagQueueResponse { export interface ReportCardRequest { anonymousId: string; + hide?: boolean; identifier: string; reason: Reason; text?: string; @@ -3720,6 +3721,7 @@ const typeMap: any = { ReportCardRequest: o( [ { json: "anonymousId", js: "anonymousId", typ: "" }, + { json: "hide", js: "hide", typ: u(undefined, true) }, { json: "identifier", js: "identifier", typ: "" }, { json: "reason", js: "reason", typ: r("Reason") }, { json: "text", js: "text", typ: u(undefined, "") }, diff --git a/frontend/src/features/modals/Modals.tsx b/frontend/src/features/modals/Modals.tsx index 62ffa08d8..2d174334a 100644 --- a/frontend/src/features/modals/Modals.tsx +++ b/frontend/src/features/modals/Modals.tsx @@ -5,6 +5,7 @@ import { MemoizedCardDetailedView } from "@/features/cardDetailedView/CardDetail import { ChangeQueryModal } from "@/features/changeQuery/ChangeQueryModal"; import { InvalidIdentifiersModal } from "@/features/invalidIdentifiers/InvalidIdentifiersModal"; import { PDFGeneratorModal } from "@/features/pdf/PDFGeneratorModal"; +import { selectHiddenCardIdentifiersSet } from "@/store/slices/hiddenCardsSlice"; import { hideModal, selectModalProps, @@ -17,6 +18,7 @@ export function Modals() { const dispatch = useAppDispatch(); const modalProps = useAppSelector(selectModalProps); const shownModal = useAppSelector(selectShownModal); + const hiddenCardIdentifiers = useAppSelector(selectHiddenCardIdentifiersSet); //# endregion @@ -32,13 +34,16 @@ export function Modals() { <> {modalProps !== null && ( <> - {"cardDetailedView" in modalProps && ( - - )} + {"cardDetailedView" in modalProps && + !hiddenCardIdentifiers.has( + modalProps.cardDetailedView.card.identifier + ) && ( + + )} {"changeQuery" in modalProps && ( { + window.localStorage.clear(); +}); +afterEach(() => { + window.localStorage.clear(); +}); + describe("ReportCardPanel", () => { it("expands the flag button into all five reason chips", () => { renderWithStore(); @@ -78,4 +98,81 @@ describe("ReportCardPanel", () => { expect(screen.queryByTestId("report-card-thanks")).toBeNull(); expect(screen.getByTestId("report-card-panel")).toBeDefined(); }); + + it("sends hide: true and dispatches the client-side exclusion when the hide checkbox is checked", async () => { + let capturedBody: { hide?: boolean } | null = null; + server.use( + http.post(buildRoute("2/reportCard/"), async ({ request }) => { + capturedBody = (await request.json()) as { hide?: boolean }; + return HttpResponse.json( + { reported: true, voteCast: true }, + { status: 200 } + ); + }) + ); + const store = renderWithStore(); + fireEvent.click(screen.getByTestId("report-card-button")); + fireEvent.click(screen.getByTestId("report-hide-checkbox")); + fireEvent.click(screen.getByTestId("report-chip-nsfw")); + await waitFor(() => + expect(screen.getByTestId("report-card-thanks")).toBeDefined() + ); + + expect(capturedBody?.hide).toBe(true); + // in-view drop via the store + localStorage mirror (written by the hideCard listener) + expect(store.getState().hiddenCards.hiddenIdentifiers).toContain( + cardDocument1.identifier + ); + expect(getLocalStorageHiddenCardIds(getOrCreateAnonymousId())).toEqual( + new Set([cardDocument1.identifier]) + ); + }); + + it("sends no hide flag when the checkbox is left unchecked", async () => { + let capturedBody: { hide?: boolean } | null = null; + server.use( + http.post(buildRoute("2/reportCard/"), async ({ request }) => { + capturedBody = (await request.json()) as { hide?: boolean }; + return HttpResponse.json( + { reported: true, voteCast: true }, + { status: 200 } + ); + }) + ); + const store = renderWithStore(); + fireEvent.click(screen.getByTestId("report-card-button")); + fireEvent.click(screen.getByTestId("report-chip-nsfw")); + await waitFor(() => + expect(screen.getByTestId("report-card-thanks")).toBeDefined() + ); + + expect(capturedBody?.hide).toBeUndefined(); + expect(store.getState().hiddenCards.hiddenIdentifiers).toHaveLength(0); + }); + + it("hides via the Other flow too when the checkbox is checked", async () => { + let capturedBody: { hide?: boolean } | null = null; + server.use( + http.post(buildRoute("2/reportCard/"), async ({ request }) => { + capturedBody = (await request.json()) as { hide?: boolean }; + return HttpResponse.json( + { reported: true, voteCast: true }, + { status: 200 } + ); + }) + ); + renderWithStore(); + fireEvent.click(screen.getByTestId("report-card-button")); + fireEvent.click(screen.getByTestId("report-hide-checkbox")); + fireEvent.click(screen.getByTestId("report-chip-other")); + fireEvent.change(screen.getByTestId("report-other-text"), { + target: { value: "something is wrong" }, + }); + fireEvent.click(screen.getByTestId("report-submit-other")); + await waitFor(() => + expect(screen.getByTestId("report-card-thanks")).toBeDefined() + ); + + expect(capturedBody?.hide).toBe(true); + }); }); diff --git a/frontend/src/features/reporting/ReportCardPanel.tsx b/frontend/src/features/reporting/ReportCardPanel.tsx index f7465eced..dbbe8b385 100644 --- a/frontend/src/features/reporting/ReportCardPanel.tsx +++ b/frontend/src/features/reporting/ReportCardPanel.tsx @@ -23,6 +23,7 @@ import { RightPaddedIcon } from "@/components/icon"; import { ChipCard } from "@/features/attributeVoting/ChipCard"; import { APIReportCard } from "@/store/api"; import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; +import { hideCard } from "@/store/slices/hiddenCardsSlice"; import { setNotification } from "@/store/slices/toastsSlice"; const REPORT_TEXT_MAX_LENGTH = 280; @@ -47,6 +48,7 @@ export function ReportCardPanel({ cardDocument }: ReportCardPanelProps) { const [showOtherText, setShowOtherText] = useState(false); const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); + const [hideForMe, setHideForMe] = useState(false); if (backendURL == null) { return null; @@ -60,8 +62,15 @@ export function ReportCardPanel({ cardDocument }: ReportCardPanelProps) { cardDocument.identifier, getOrCreateAnonymousId(), reason, - text + text, + hideForMe ); + if (hideForMe) { + // issue #714: the modal disappears via Modals.tsx's hidden-identifier gate; the + // localStorage mirror (listenerMiddleware) keeps this session in sync, and the + // server-side question-feed filter is the durable mechanism. + dispatch(hideCard(cardDocument.identifier)); + } setSubmitted(true); } catch (error) { dispatch( @@ -128,6 +137,16 @@ export function ReportCardPanel({ cardDocument }: ReportCardPanelProps) { ))} + setHideForMe(event.target.checked)} + disabled={submitting} + data-testid="report-hide-checkbox" + /> {showOtherText && ( <> 0) { dispatch(setAllManualOverrides(manualOverrides)); } + // Hydrate the hidden-card mirror for the existing anonymous identity only - never mint a + // new one just by loading (issue #714); a hide writes the same set back via listener. + const existingAnonymousId = getExistingAnonymousId(); + if (existingAnonymousId != null) { + const hiddenIdentifiers = + getLocalStorageHiddenCardIds(existingAnonymousId); + if (hiddenIdentifiers.size > 0) { + dispatch(setAllHiddenCardIdentifiers([...hiddenIdentifiers])); + } + } clientSearchService.initialiseWorker(); pdfRenderService.initialiseWorker(); }, []); diff --git a/frontend/src/store/api.ts b/frontend/src/store/api.ts index 02583324a..ea0e9adb7 100644 --- a/frontend/src/store/api.ts +++ b/frontend/src/store/api.ts @@ -981,11 +981,12 @@ export async function APIReportCard( identifier: string, anonymousId: string, reason: ReportReason, - text?: string + text?: string, + hide?: boolean ): Promise { const rawResponse = await fetch(formatURL(backendURL, "/2/reportCard/"), { method: "POST", - body: JSON.stringify({ identifier, anonymousId, reason, text }), + body: JSON.stringify({ identifier, anonymousId, reason, text, hide }), credentials: "include", headers: getCSRFHeader(), }); diff --git a/frontend/src/store/listenerMiddleware.ts b/frontend/src/store/listenerMiddleware.ts index 229ad592a..7be8f3cee 100644 --- a/frontend/src/store/listenerMiddleware.ts +++ b/frontend/src/store/listenerMiddleware.ts @@ -6,8 +6,10 @@ import { import { Back, Front, QueryTags } from "@/common/constants"; import { + getExistingAnonymousId, getLocalStorageSearchSettings, setLocalStorageFavorites, + setLocalStorageHiddenCardIds, setLocalStorageManualOverrides, } from "@/common/cookies"; import { isLikelyDriveFileId } from "@/common/orphanCard"; @@ -37,6 +39,7 @@ import { setFavoriteRender, toggleFavoriteRender, } from "@/store/slices/favoritesSlice"; +import { hideCard } from "@/store/slices/hiddenCardsSlice"; import { recordInvalidIdentifier } from "@/store/slices/invalidIdentifiersSlice"; import { addMembers, @@ -432,4 +435,23 @@ startAppListening({ }, }); +startAppListening({ + actionCreator: hideCard, + /** + * Persist the per-anonymous_id hidden set to localStorage on every hide (issue #714), so + * the client-side mirror survives a reload. A hide only ever follows a successful report + * submission, which has already minted the anonymous id - so the id always exists here. + */ + effect: async (action, { getState }) => { + const anonymousId = getExistingAnonymousId(); + if (anonymousId == null) { + return; + } + setLocalStorageHiddenCardIds( + anonymousId, + getState().hiddenCards.hiddenIdentifiers + ); + }, +}); + //# endregion diff --git a/frontend/src/store/slices/hiddenCardsSlice.ts b/frontend/src/store/slices/hiddenCardsSlice.ts new file mode 100644 index 000000000..fd90bd816 --- /dev/null +++ b/frontend/src/store/slices/hiddenCardsSlice.ts @@ -0,0 +1,66 @@ +import { createSelector, PayloadAction } from "@reduxjs/toolkit"; + +import { createAppSlice } from "@/common/types"; +import { RootState } from "@/store/store"; + +//# region slice configuration + +/** + * Identifiers of cards this visitor hid for themselves via a `hide=True` card report + * (issue #714 - see docs/features/moderation.md's hidden-card section). Mirrors the + * server-side `HiddenCard` rows for the current anonymous identity: hydrated once at app + * start from localStorage and written through on every `hideCard` (listener middleware), so + * the current session's views drop a hidden card immediately on dispatch without waiting for + * a refetch (the server-side question-feed filter is the durable mechanism). + */ +export interface HiddenCardsState { + hiddenIdentifiers: string[]; +} + +const initialState: HiddenCardsState = { + hiddenIdentifiers: [], +}; + +export const hiddenCardsSlice = createAppSlice({ + name: "hiddenCards", + initialState, + reducers: { + /** + * Replace the full hidden set (e.g., when hydrating from localStorage at app start). + * @param hiddenIdentifiers - The complete hidden identifiers to set + */ + setAllHiddenCardIdentifiers: (state, action: PayloadAction) => { + state.hiddenIdentifiers = action.payload; + }, + /** + * Hide a card for the current anonymous identity. + * @param identifier - The card identifier to hide + */ + hideCard: (state, action: PayloadAction) => { + if (!state.hiddenIdentifiers.includes(action.payload)) { + state.hiddenIdentifiers.push(action.payload); + } + }, + }, +}); + +export const { setAllHiddenCardIdentifiers, hideCard } = + hiddenCardsSlice.actions; +export default hiddenCardsSlice.reducer; + +//# endregion + +//# region selectors + +export const selectHiddenCardIdentifiers = (state: RootState): string[] => + state.hiddenCards.hiddenIdentifiers; + +/** + * Returns a Set of hidden identifiers for fast O(1) lookup. + */ +export const selectHiddenCardIdentifiersSet = createSelector( + (state: RootState) => state.hiddenCards.hiddenIdentifiers, + (hiddenIdentifiers) => new Set(hiddenIdentifiers) +); + +//# endregion diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index 2bc1e032f..efcaaa3dc 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -21,6 +21,7 @@ import cardSpacingReducer from "@/store/slices/cardSpacingSlice"; import favoritesReducer from "@/store/slices/favoritesSlice"; import fileDownloadsReducer from "@/store/slices/fileDownloadsSlice"; import finishSettingsReducer from "@/store/slices/finishSettingsSlice"; +import hiddenCardsReducer from "@/store/slices/hiddenCardsSlice"; import invalidIdentifiersReducer from "@/store/slices/invalidIdentifiersSlice"; import marginProfileReducer from "@/store/slices/marginProfileSlice"; import modalsReducer from "@/store/slices/modalsSlice"; @@ -50,6 +51,7 @@ const rootReducer = combineReducers({ invalidIdentifiers: invalidIdentifiersReducer, fileDownloads: fileDownloadsReducer, favorites: favoritesReducer, + hiddenCards: hiddenCardsReducer, savedDeckSession: savedDeckSessionReducer, // Homepage participation graph's in-session "you contributed" state - see // features/stats/sessionContributionSlice.ts's own module comment. diff --git a/schemas/schemas/endpoints/ReportCardRequest.json b/schemas/schemas/endpoints/ReportCardRequest.json index 172ce335c..a5110eff3 100644 --- a/schemas/schemas/endpoints/ReportCardRequest.json +++ b/schemas/schemas/endpoints/ReportCardRequest.json @@ -9,7 +9,8 @@ "type": "string", "enum": ["nsfw", "low_quality", "wrong_card", "broken_image", "other"] }, - "text": { "type": "string", "maxLength": 280 } + "text": { "type": "string", "maxLength": 280 }, + "hide": { "type": "boolean" } }, "required": ["identifier", "anonymousId", "reason"], "additionalProperties": false From 484aaaa4b8699f6a69a3ed48c730b73f2b3fdf94 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:41:36 +0000 Subject: [PATCH 2/3] refactor(migrations): renumber hiddencard migration to 0108 depending on 0107 --- .../migrations/{0106_hiddencard.py => 0108_hiddencard.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename MPCAutofill/cardpicker/migrations/{0106_hiddencard.py => 0108_hiddencard.py} (93%) diff --git a/MPCAutofill/cardpicker/migrations/0106_hiddencard.py b/MPCAutofill/cardpicker/migrations/0108_hiddencard.py similarity index 93% rename from MPCAutofill/cardpicker/migrations/0106_hiddencard.py rename to MPCAutofill/cardpicker/migrations/0108_hiddencard.py index 0cdaedd1b..00f60037f 100644 --- a/MPCAutofill/cardpicker/migrations/0106_hiddencard.py +++ b/MPCAutofill/cardpicker/migrations/0108_hiddencard.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ("cardpicker", "0105_question_feed_pools_schedule"), + ("cardpicker", "0107_question_feed_pools_schedule_dedupe"), ] operations = [ From da8e86439819b022cc4016f779766e9f3cd9295f Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:16:21 +0000 Subject: [PATCH 3/3] fix(reporting): omit hide from payload unless checked; prettier-format carried files --- frontend/src/features/reporting/ReportCardPanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/reporting/ReportCardPanel.tsx b/frontend/src/features/reporting/ReportCardPanel.tsx index dbbe8b385..601a4c4f2 100644 --- a/frontend/src/features/reporting/ReportCardPanel.tsx +++ b/frontend/src/features/reporting/ReportCardPanel.tsx @@ -63,7 +63,9 @@ export function ReportCardPanel({ cardDocument }: ReportCardPanelProps) { getOrCreateAnonymousId(), reason, text, - hideForMe + // `undefined` (not `false`) when unchecked: JSON.stringify drops undefined, so the + // wire payload is exactly the old one when the user doesn't opt in to hiding. + hideForMe || undefined ); if (hideForMe) { // issue #714: the modal disappears via Modals.tsx's hidden-identifier gate; the