fix(privacy): let minors block media in DMs from others (#3120)

This commit is contained in:
Hampus
2026-10-02 17:23:50 +02:00
committed by GitHub
parent 76e6891f5b
commit d87351eefe
3 changed files with 58 additions and 21 deletions
@@ -226,11 +226,17 @@ export class UserAccountSettingsService {
updatedRowData.sensitive_content_friend_dm_filter = data.sensitive_content_friend_dm_filter; updatedRowData.sensitive_content_friend_dm_filter = data.sensitive_content_friend_dm_filter;
} }
if (data.sensitive_content_non_friend_dm_filter !== undefined) { if (data.sensitive_content_non_friend_dm_filter !== undefined) {
throw ValidationError.fromPath( const allowed =
'sensitive_content_non_friend_dm_filter', data.sensitive_content_non_friend_dm_filter === SensitiveMediaFilterLevel.BLUR ||
'AGE_RESTRICTED', data.sensitive_content_non_friend_dm_filter === SensitiveMediaFilterLevel.BLOCK;
'Non-adult users cannot modify the non-friend DM content filter', if (!allowed) {
); throw ValidationError.fromPath(
'sensitive_content_non_friend_dm_filter',
'AGE_RESTRICTED',
'Non-adult users can only set non-friend DM filter to blur or block',
);
}
updatedRowData.sensitive_content_non_friend_dm_filter = data.sensitive_content_non_friend_dm_filter;
} }
if (data.sensitive_content_guild_filter !== undefined) { if (data.sensitive_content_guild_filter !== undefined) {
throw ValidationError.fromPath( throw ValidationError.fromPath(
@@ -100,6 +100,35 @@ describe('User Settings - Sensitive Content Filters', () => {
expect(response.errors[0]?.path).toBe('sensitive_content_friend_dm_filter'); expect(response.errors[0]?.path).toBe('sensitive_content_friend_dm_filter');
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.AGE_RESTRICTED); expect(response.errors[0]?.code).toBe(ValidationErrorCodes.AGE_RESTRICTED);
}); });
test('lets a non-adult account tighten the non-friend DM filter', async () => {
const account = await createTestAccount(harness, {dateOfBirth: '2010-01-01'});
const {json} = await updateUserSettings(harness, account.token, {
sensitive_content_non_friend_dm_filter: SensitiveMediaFilterLevel.BLUR,
});
expect(json.sensitive_content_non_friend_dm_filter).toBe(SensitiveMediaFilterLevel.BLUR);
const {json: blocked} = await updateUserSettings(harness, account.token, {
sensitive_content_non_friend_dm_filter: SensitiveMediaFilterLevel.BLOCK,
});
expect(blocked.sensitive_content_non_friend_dm_filter).toBe(SensitiveMediaFilterLevel.BLOCK);
});
test('rejects a non-adult account relaxing the non-friend DM filter', async () => {
const account = await createTestAccount(harness, {dateOfBirth: '2010-01-01'});
const response = await createBuilder<{
code: string;
errors: Array<{
path: string;
code: string;
message: string;
}>;
}>(harness, account.token)
.patch('/users/@me/settings')
.body({sensitive_content_non_friend_dm_filter: SensitiveMediaFilterLevel.SHOW})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(response.code).toBe('VALIDATION_ERROR');
expect(response.errors[0]?.path).toBe('sensitive_content_non_friend_dm_filter');
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.AGE_RESTRICTED);
});
test('allows an account without a birth date to relax every filter when none is collected', async () => { test('allows an account without a birth date to relax every filter when none is collected', async () => {
const repository = getInstanceConfigRepository(); const repository = getInstanceConfigRepository();
const previous = (await repository.getAppPublicConfig()).registration.collect_date_of_birth; const previous = (await repository.getAppPublicConfig()).registration.collect_date_of_birth;
@@ -51,6 +51,7 @@ const SENSITIVE_CONTENT_TAB_ID = 'privacy_safety';
interface SensitiveContentOption { interface SensitiveContentOption {
value: number; value: number;
label: string; label: string;
disabled?: boolean;
} }
interface SensitiveContentChoiceRowProps { interface SensitiveContentChoiceRowProps {
@@ -73,15 +74,16 @@ const SensitiveContentChoiceRow: React.FC<SensitiveContentChoiceRowProps> = ({
const labelId = useId(); const labelId = useId();
const optionRefs = useRef(new Map<number, HTMLButtonElement>()); const optionRefs = useRef(new Map<number, HTMLButtonElement>());
const selectedIndex = options.findIndex((option) => option.value === value); const selectedIndex = options.findIndex((option) => option.value === value);
const focusedIndex = selectedIndex >= 0 ? selectedIndex : 0; const enabledOptions = options.filter((option) => !option.disabled);
const focusedValue = enabledOptions.some((option) => option.value === value) ? value : enabledOptions[0]?.value;
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, optionValue: number) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, optionValue: number) => {
if (disabled) return; if (disabled) return;
const currentIndex = options.findIndex((option) => option.value === optionValue); const currentIndex = enabledOptions.findIndex((option) => option.value === optionValue);
if (currentIndex < 0) return; if (currentIndex < 0) return;
const direction = getTabNavigationDirection(event.key, 'horizontal'); const direction = getTabNavigationDirection(event.key, 'horizontal');
if (!direction) return; if (!direction) return;
const nextIndex = getNextTabIndex(currentIndex, options.length, direction); const nextIndex = getNextTabIndex(currentIndex, enabledOptions.length, direction);
const nextOption = nextIndex == null ? null : options[nextIndex]; const nextOption = nextIndex == null ? null : enabledOptions[nextIndex];
if (!nextOption) return; if (!nextOption) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -101,7 +103,7 @@ const SensitiveContentChoiceRow: React.FC<SensitiveContentChoiceRowProps> = ({
aria-disabled={disabled || undefined} aria-disabled={disabled || undefined}
data-flx={dataFlx} data-flx={dataFlx}
> >
{options.map((option, index) => { {options.map((option) => {
const isSelected = option.value === value; const isSelected = option.value === value;
return ( return (
<button <button
@@ -116,8 +118,8 @@ const SensitiveContentChoiceRow: React.FC<SensitiveContentChoiceRowProps> = ({
type="button" type="button"
role="radio" role="radio"
aria-checked={isSelected} aria-checked={isSelected}
tabIndex={!disabled && index === focusedIndex ? 0 : -1} tabIndex={!disabled && option.value === focusedValue ? 0 : -1}
disabled={disabled} disabled={disabled || option.disabled}
className={clsx(styles.choiceButton, isSelected && styles.choiceButtonActive)} className={clsx(styles.choiceButton, isSelected && styles.choiceButtonActive)}
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
onKeyDown={(event) => handleKeyDown(event, option.value)} onKeyDown={(event) => handleKeyDown(event, option.value)}
@@ -161,11 +163,10 @@ export const SensitiveContentTabContent: React.FC = observer(() => {
const [nonFriendDmFilter, setNonFriendDmFilter] = useState(UserSettings.sensitiveContentNonFriendDmFilter); const [nonFriendDmFilter, setNonFriendDmFilter] = useState(UserSettings.sensitiveContentNonFriendDmFilter);
const [guildFilter, setGuildFilter] = useState(UserSettings.sensitiveContentGuildFilter); const [guildFilter, setGuildFilter] = useState(UserSettings.sensitiveContentGuildFilter);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const hasUnsavedChanges = isMatureContentAllowed const hasUnsavedChanges =
? friendDmFilter !== UserSettings.sensitiveContentFriendDmFilter || friendDmFilter !== UserSettings.sensitiveContentFriendDmFilter ||
nonFriendDmFilter !== UserSettings.sensitiveContentNonFriendDmFilter || nonFriendDmFilter !== UserSettings.sensitiveContentNonFriendDmFilter ||
guildFilter !== UserSettings.sensitiveContentGuildFilter (isMatureContentAllowed && guildFilter !== UserSettings.sensitiveContentGuildFilter);
: friendDmFilter !== UserSettings.sensitiveContentFriendDmFilter;
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setFriendDmFilter(UserSettings.sensitiveContentFriendDmFilter); setFriendDmFilter(UserSettings.sensitiveContentFriendDmFilter);
setNonFriendDmFilter(UserSettings.sensitiveContentNonFriendDmFilter); setNonFriendDmFilter(UserSettings.sensitiveContentNonFriendDmFilter);
@@ -183,6 +184,7 @@ export const SensitiveContentTabContent: React.FC = observer(() => {
} else { } else {
await UserSettingsCommands.update({ await UserSettingsCommands.update({
sensitiveContentFriendDmFilter: friendDmFilter, sensitiveContentFriendDmFilter: friendDmFilter,
sensitiveContentNonFriendDmFilter: nonFriendDmFilter,
}); });
} }
} finally { } finally {
@@ -212,8 +214,9 @@ export const SensitiveContentTabContent: React.FC = observer(() => {
], ],
[i18n.locale], [i18n.locale],
); );
const teenFriendDmOptions = useMemo( const teenDmOptions = useMemo(
() => [ () => [
{value: SensitiveMediaFilterLevel.SHOW, label: i18n._(SHOW_DESCRIPTOR), disabled: true},
{value: SensitiveMediaFilterLevel.BLUR, label: i18n._(BLUR_DESCRIPTOR)}, {value: SensitiveMediaFilterLevel.BLUR, label: i18n._(BLUR_DESCRIPTOR)},
{value: SensitiveMediaFilterLevel.BLOCK, label: i18n._(BLOCK_DESCRIPTOR)}, {value: SensitiveMediaFilterLevel.BLOCK, label: i18n._(BLOCK_DESCRIPTOR)},
], ],
@@ -234,7 +237,7 @@ export const SensitiveContentTabContent: React.FC = observer(() => {
<SensitiveContentChoiceRow <SensitiveContentChoiceRow
label={i18n._(DIRECT_MESSAGES_FROM_FRIENDS_DESCRIPTOR)} label={i18n._(DIRECT_MESSAGES_FROM_FRIENDS_DESCRIPTOR)}
value={friendDmFilter} value={friendDmFilter}
options={isMatureContentAllowed ? filterOptions : teenFriendDmOptions} options={isMatureContentAllowed ? filterOptions : teenDmOptions}
onChange={setFriendDmFilter} onChange={setFriendDmFilter}
dataFlx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.select.set-friend-dm-filter" dataFlx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.select.set-friend-dm-filter"
data-flx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.sensitive-content-choice-row.set-friend-dm-filter" data-flx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.sensitive-content-choice-row.set-friend-dm-filter"
@@ -242,9 +245,8 @@ export const SensitiveContentTabContent: React.FC = observer(() => {
<SensitiveContentChoiceRow <SensitiveContentChoiceRow
label={i18n._(DIRECT_MESSAGES_FROM_OTHERS_DESCRIPTOR)} label={i18n._(DIRECT_MESSAGES_FROM_OTHERS_DESCRIPTOR)}
value={nonFriendDmFilter} value={nonFriendDmFilter}
options={filterOptions} options={isMatureContentAllowed ? filterOptions : teenDmOptions}
onChange={setNonFriendDmFilter} onChange={setNonFriendDmFilter}
disabled={!isMatureContentAllowed}
dataFlx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.select.set-non-friend-dm-filter" dataFlx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.select.set-non-friend-dm-filter"
data-flx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.sensitive-content-choice-row.set-non-friend-dm-filter" data-flx="user.privacy-safety-tab.sensitive-content-tab.sensitive-content-tab-content.sensitive-content-choice-row.set-non-friend-dm-filter"
/> />