From 1d084dcf1b007f533b4b8aea101bb684e0c2c896 Mon Sep 17 00:00:00 2001 From: Marco Filho Date: Sat, 12 Sep 2026 01:16:39 -0300 Subject: [PATCH 1/2] [194] Unblur videos on hover and centralize the wellness filter Co-Authored-By: Claude Opus 5 (1M context) --- client/src/models/safetySettings.test.ts | 64 ++++++++++ client/src/models/safetySettings.ts | 49 ++++++++ ...nualReviewJobContentBlurableImage.test.tsx | 83 +++++++++++++ .../ManualReviewJobContentBlurableImage.tsx | 25 +++- ...nualReviewJobContentBlurableVideo.test.tsx | 115 ++++++++++++++++++ .../ManualReviewJobContentBlurableVideo.tsx | 38 +++--- .../v2/ManualReviewJobFieldsComponent.tsx | 4 + .../v2/ncmec/NCMECMediaViewer.tsx | 15 ++- .../v2/ncmec/NCMECReviewUser.tsx | 30 ++--- .../src/webpages/settings/AccountSettings.tsx | 14 ++- .../webpages/settings/tabs/WellnessTab.tsx | 14 ++- docs/user/review-console.md | 2 +- 12 files changed, 404 insertions(+), 49 deletions(-) create mode 100644 client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.test.tsx create mode 100644 client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableVideo.test.tsx diff --git a/client/src/models/safetySettings.test.ts b/client/src/models/safetySettings.test.ts index ff049303..5de0e53d 100644 --- a/client/src/models/safetySettings.test.ts +++ b/client/src/models/safetySettings.test.ts @@ -1,6 +1,7 @@ import { colorSchemeClassName, colorSchemeFromPreferences, + moderatorSafetyFilterStyle, preferencesFromColorScheme, } from './safetySettings'; @@ -75,3 +76,66 @@ describe('safetySettings color scheme', () => { ).toBe('grayscale'); }); }); + +describe('moderatorSafetyFilterStyle', () => { + it('keeps the pixel value each blur level rendered before', () => { + const pixelsByLevel = [0, 4, 8, 12, 16, 24, 40]; + pixelsByLevel.forEach((pixels, level) => { + expect( + moderatorSafetyFilterStyle({ blurLevel: level, shouldBlur: true }), + ).toBe(level === 0 ? undefined : `blur(${pixels}px)`); + }); + }); + + it('drops the blur when this media should not be blurred', () => { + expect( + moderatorSafetyFilterStyle({ blurLevel: 6, shouldBlur: false }), + ).toBeUndefined(); + }); + + it('clamps levels outside the configurable range', () => { + expect( + moderatorSafetyFilterStyle({ blurLevel: 99, shouldBlur: true }), + ).toBe('blur(40px)'); + expect( + moderatorSafetyFilterStyle({ blurLevel: -1, shouldBlur: true }), + ).toBeUndefined(); + }); + + it('composes blur and color scheme', () => { + expect( + moderatorSafetyFilterStyle({ + blurLevel: 3, + shouldBlur: true, + grayscale: true, + }), + ).toBe('blur(12px) grayscale(100%)'); + expect( + moderatorSafetyFilterStyle({ + blurLevel: 3, + shouldBlur: true, + sepia: true, + }), + ).toBe('blur(12px) sepia(100%)'); + }); + + it('applies a color scheme on its own', () => { + expect(moderatorSafetyFilterStyle({ grayscale: true })).toBe( + 'grayscale(100%)', + ); + expect(moderatorSafetyFilterStyle({ sepia: true })).toBe('sepia(100%)'); + }); + + it('never applies both color filters, like the settings screens', () => { + expect(moderatorSafetyFilterStyle({ grayscale: true, sepia: true })).toBe( + 'grayscale(100%)', + ); + }); + + it('returns undefined when there is nothing to apply', () => { + expect(moderatorSafetyFilterStyle({})).toBeUndefined(); + expect( + moderatorSafetyFilterStyle({ blurLevel: 0, shouldBlur: true }), + ).toBeUndefined(); + }); +}); diff --git a/client/src/models/safetySettings.ts b/client/src/models/safetySettings.ts index 71d72669..0d7ac8bb 100644 --- a/client/src/models/safetySettings.ts +++ b/client/src/models/safetySettings.ts @@ -60,3 +60,52 @@ export function preferencesFromColorScheme( moderatorSafetySepia: colorScheme === 'SEPIA', }; } + +// Each blur level keeps the pixel value its Tailwind `blur-*` class applied +// before wellness filters became a single inline `filter`. +const BLUR_PIXELS_BY_LEVEL = [0, 4, 8, 12, 16, 24, 40]; + +export const MAX_BLUR_LEVEL = BLUR_PIXELS_BY_LEVEL.length - 1; + +// Single source for the `filter` applied to reviewed media, so every surface +// blurs and tints alike. Returns undefined when nothing applies, which leaves +// the style off the element entirely. +export function moderatorSafetyFilterStyle(options: { + blurLevel?: number; + shouldBlur?: boolean; + grayscale?: boolean; + sepia?: boolean; +}): string | undefined { + const { + blurLevel = 0, + shouldBlur = false, + grayscale = false, + sepia = false, + } = options; + const filters: string[] = []; + + if (shouldBlur) { + const level = Math.min(Math.max(Math.trunc(blurLevel), 0), MAX_BLUR_LEVEL); + const pixels = BLUR_PIXELS_BY_LEVEL[level]; + if (pixels > 0) { + filters.push(`blur(${pixels}px)`); + } + } + + const colorScheme = colorSchemeFromPreferences({ + moderatorSafetyGrayscale: grayscale, + moderatorSafetySepia: sepia, + }); + switch (colorScheme) { + case 'GRAYSCALE': + filters.push('grayscale(100%)'); + break; + case 'SEPIA': + filters.push('sepia(100%)'); + break; + case 'NONE': + break; + } + + return filters.length > 0 ? filters.join(' ') : undefined; +} diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.test.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.test.tsx new file mode 100644 index 00000000..6b120231 --- /dev/null +++ b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.test.tsx @@ -0,0 +1,83 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ComponentProps } from 'react'; +import { vi } from 'vitest'; + +import ManualReviewJobContentBlurableImage from './ManualReviewJobContentBlurableImage'; + +type ImageOptions = NonNullable< + ComponentProps['options'] +>; + +function renderImage(options: ImageOptions) { + const { container } = render( + , + ); + const image = container.querySelector('img'); + if (image == null) { + throw new Error('expected the component to render an image'); + } + return { wrapper: container.firstElementChild as HTMLElement, image }; +} + +describe('ManualReviewJobContentBlurableImage', () => { + it('blurs with the pixel value of the configured level', () => { + const { image } = renderImage({ shouldBlur: true, blurStrength: 3 }); + expect(image.style.filter).toBe('blur(12px)'); + }); + + it('applies the color scheme alongside the blur', () => { + const { image } = renderImage({ + shouldBlur: true, + blurStrength: 3, + grayscale: true, + }); + expect(image.style.filter).toBe('blur(12px) grayscale(100%)'); + }); + + it('reveals the image while hovered and keeps the color scheme', () => { + const { wrapper, image } = renderImage({ + shouldBlur: true, + blurStrength: 3, + grayscale: true, + }); + + userEvent.hover(wrapper); + expect(image.style.filter).toBe('grayscale(100%)'); + + userEvent.unhover(wrapper); + expect(image.style.filter).toBe('blur(12px) grayscale(100%)'); + }); + + it('keeps the blur while hovered when revealing is turned off', () => { + const { wrapper, image } = renderImage({ + shouldBlur: true, + blurStrength: 3, + revealOnHover: false, + }); + + userEvent.hover(wrapper); + expect(image.style.filter).toBe('blur(12px)'); + }); + + it('leaves media unfiltered when it should not be blurred', () => { + const { image } = renderImage({ shouldBlur: false, blurStrength: 3 }); + expect(image.style.filter).toBe(''); + }); + + it('does not read or write browser storage', () => { + const getItem = vi.spyOn(Storage.prototype, 'getItem'); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + + renderImage({ shouldBlur: true, blurStrength: 3 }); + + expect(getItem).not.toHaveBeenCalled(); + expect(setItem).not.toHaveBeenCalled(); + + getItem.mockRestore(); + setItem.mockRestore(); + }); +}); diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.tsx index 32f58cfe..d7463e56 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableImage.tsx @@ -1,9 +1,10 @@ +import { moderatorSafetyFilterStyle } from '@/models/safetySettings'; import { useEffect, useState } from 'react'; import CopyTextComponent from '../../../../components/common/CopyTextComponent'; import CoopModal from '../../components/CoopModal'; -import { BLUR_LEVELS, BlurStrength } from './v2/ncmec/NCMECMediaViewer'; +import type { BlurStrength } from './v2/ncmec/NCMECMediaViewer'; export default function ManualReviewJobContentBlurableImage(props: { url: string; @@ -15,6 +16,7 @@ export default function ManualReviewJobContentBlurableImage(props: { grayscale?: boolean; disableZoom?: boolean; sepia?: boolean; + revealOnHover?: boolean; }; onError?: () => void; }) { @@ -27,10 +29,12 @@ export default function ManualReviewJobContentBlurableImage(props: { grayscale = false, disableZoom = false, sepia = false, + revealOnHover = true, } = options ?? {}; const [clicked, setClicked] = useState(false); const [error, setError] = useState(false); + const [isHovered, setIsHovered] = useState(false); // Reset error when the url changes useEffect(() => setError(false), [url]); @@ -46,16 +50,25 @@ export default function ManualReviewJobContentBlurableImage(props: { ); } + const filter = moderatorSafetyFilterStyle({ + blurLevel: blurStrength, + shouldBlur: shouldBlur && !(revealOnHover && isHovered), + grayscale, + sepia, + }); + return ( -
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > setClicked(true)} - style={{ maxWidth, maxHeight }} + style={{ maxWidth, maxHeight, filter }} onError={() => { setError(true); onError?.(); diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableVideo.test.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableVideo.test.tsx new file mode 100644 index 00000000..442d6e1f --- /dev/null +++ b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobContentBlurableVideo.test.tsx @@ -0,0 +1,115 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ComponentProps } from 'react'; +import { vi } from 'vitest'; + +import ManualReviewJobContentBlurableVideo from './ManualReviewJobContentBlurableVideo'; + +const { playerProps } = vi.hoisted(() => ({ + playerProps: [] as Record[], +})); + +function ReactPlayerMock(props: Record) { + playerProps.push(props); + return