pl-fe: add option to disable displaying user-provided media

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-24 21:51:25 +02:00
parent 19a2798427
commit 7aaf4c2431
12 changed files with 197 additions and 54 deletions

View File

@ -14,6 +14,7 @@ import VerificationBadge from 'pl-fe/components/verification-badge';
import Emojify from 'pl-fe/features/emoji/emojify';
import ActionButton from 'pl-fe/features/ui/components/action-button';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { getAcct } from 'pl-fe/utils/accounts';
import { displayFqn } from 'pl-fe/utils/state';
@ -140,6 +141,7 @@ const Account = ({
const me = useAppSelector((state) => state.me);
const username = useAppSelector((state) => account ? getAcct(account, displayFqn(state)) : null);
const { disableUserProvidedMedia } = useSettings();
const handleAction = () => {
onActionClick!(account);
@ -220,16 +222,20 @@ const Account = ({
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
<HStack alignItems={actionAlignment} space={3} justifyContent='between'>
<HStack alignItems='center' space={3} className='max-w-full'>
<div className='rounded-lg'>
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} isCat={account.is_cat} />
{emoji && (
<Emoji
className='!absolute -right-1.5 bottom-0 size-5'
emoji={emoji}
src={emojiUrl}
/>
)}
</div>
{disableUserProvidedMedia ? (
<Avatar src={account.avatar} alt={account.avatar_description} />
) : (
<div className='rounded-lg'>
<Avatar src={account.avatar} size={avatarSize} alt={account.avatar_description} isCat={account.is_cat} />
{emoji && (
<Emoji
className='!absolute -right-1.5 bottom-0 size-5'
emoji={emoji}
src={emojiUrl}
/>
)}
</div>
)}
<div className='grow overflow-hidden'>
<HStack space={1} alignItems='center' grow>
@ -250,7 +256,7 @@ const Account = ({
<HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && (
{account.favicon && !disableUserProvidedMedia && (
<InstanceFavicon account={account} disabled />
)}
@ -271,7 +277,9 @@ const Account = ({
<div data-testid='account' className='group block w-full shrink-0' ref={overflowRef}>
<HStack alignItems={actionAlignment} space={3} justifyContent='between'>
<HStack alignItems={withAccountNote || note ? 'top' : 'center'} space={3} className='max-w-full'>
{withAvatar && (
{withAvatar && (disableUserProvidedMedia ? (
<Avatar src={account.avatar} alt={account.avatar_description} />
) : (
<ProfilePopper
condition={showAccountHoverCard}
wrapper={(children) => <HoverAccountWrapper className='relative' accountId={account.id} element='span'>{children}</HoverAccountWrapper>}
@ -287,7 +295,7 @@ const Account = ({
)}
</LinkEl>
</ProfilePopper>
)}
))}
<div className='grow overflow-hidden' style={style}>
<ProfilePopper
@ -315,7 +323,7 @@ const Account = ({
<HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text>
{account.favicon && (
{account.favicon && !disableUserProvidedMedia && (
<InstanceFavicon account={account} disabled={!withLinkToProfile} />
)}

View File

@ -6,16 +6,17 @@ import Icon from 'pl-fe/components/ui/icon';
interface IAltIndicator extends Pick<React.HTMLAttributes<HTMLSpanElement>, 'title' | 'className'> {
warning?: boolean;
message?: JSX.Element;
}
const AltIndicator: React.FC<IAltIndicator> = React.forwardRef<HTMLSpanElement, IAltIndicator>(({ className, warning, ...props }, ref) => (
const AltIndicator: React.FC<IAltIndicator> = React.forwardRef<HTMLSpanElement, IAltIndicator>(({ className, warning, message, ...props }, ref) => (
<span
className={clsx('inline-flex items-center gap-1 rounded bg-gray-900 px-2 py-1 text-xs font-medium uppercase text-white', className)}
{...props}
ref={ref}
>
{warning && <Icon className='size-4' src={require('@tabler/icons/outline/alert-triangle.svg')} />}
<FormattedMessage id='upload_form.description_missing.indicator' defaultMessage='Alt' />
{message || <FormattedMessage id='upload_form.description_missing.indicator' defaultMessage='Alt' />}
</span>
));

View File

@ -17,6 +17,8 @@ import { truncateFilename } from 'pl-fe/utils/media';
import { isIOS } from '../is-mobile';
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio';
import HStack from './ui/hstack';
import type { MediaAttachment } from 'pl-api';
const ATTACHMENT_LIMIT = 4;
@ -315,14 +317,53 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
visible,
} = props;
const { disableUserProvidedMedia } = useSettings();
const [width, setWidth] = useState<number>(defaultWidth);
const node = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (node.current) {
const { offsetWidth } = node.current;
if (cacheWidth) {
cacheWidth(offsetWidth);
}
setWidth(offsetWidth);
}
}, [node.current]);
const handleClick = (index: number) => {
onOpenMedia(media, index);
};
if (disableUserProvidedMedia) {
return (
<Stack space={2}>
{media.map((attachment, index) => (
<HStack element='button' alignItems='center' space={2} key={attachment.id} onClick={() => handleClick(index)}>
<Icon
className='size-4 min-w-fit text-gray-800 dark:text-gray-200'
src={MIMETYPE_ICONS[(attachment.type === 'unknown' && attachment.mime_type) || attachment.type] || require('@tabler/icons/outline/paperclip.svg')}
/>
<Text align='left'>
{attachment.description || {
image: <FormattedMessage id='media.default_description.image' defaultMessage='Image' />,
video: <FormattedMessage id='media.default_description.video' defaultMessage='Video' />,
gifv: <FormattedMessage id='media.default_description.gifv' defaultMessage='GIFV' />,
audio: <FormattedMessage id='media.default_description.audio' defaultMessage='Audio' />,
unknown: <FormattedMessage id='media.default_description.attachment' defaultMessage='Attachment' />,
}[attachment.type]}
</Text>
</HStack>
// <MediaItem key={index} item={item} />
))}
</Stack>
);
}
const getSizeDataSingle = (): SizeData => {
const w = width || defaultWidth;
const aspectRatio = getAspectRatio(media[0]);
@ -564,18 +605,6 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
/>
));
useLayoutEffect(() => {
if (node.current) {
const { offsetWidth } = node.current;
if (cacheWidth) {
cacheWidth(offsetWidth);
}
setWidth(offsetWidth);
}
}, [node.current]);
return (
<div
className={clsx(className, 'media-gallery overflow-hidden rounded-md', {

View File

@ -1,11 +1,17 @@
import clsx from 'clsx';
import { FastAverageColor } from 'fast-average-color';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import StillImage, { IStillImage } from 'pl-fe/components/still-image';
import { useSettings } from 'pl-fe/hooks/use-settings';
import AltIndicator from '../alt-indicator';
import Icon from './icon';
import Popover from './popover';
import Stack from './stack';
import Text from './text';
const COLOR_CACHE = new Map<string, string>();
@ -27,6 +33,7 @@ const fac = new FastAverageColor();
/** Round profile avatar for accounts. */
const Avatar = (props: IAvatar) => {
const intl = useIntl();
const { disableUserProvidedMedia } = useSettings();
const { alt, src, size = AVATAR_SIZE, className, isCat } = props;
@ -58,6 +65,29 @@ const Avatar = (props: IAvatar) => {
color,
}), [size, color]);
if (disableUserProvidedMedia) {
if (isAvatarMissing || !alt) return null;
return (
<Popover
interaction='hover'
referenceElementClassName='cursor-pointer'
content={
<Stack space={1} className='max-h-[32rem] max-w-96 overflow-auto p-4'>
<Text weight='semibold'>
<FormattedMessage id='account.avatar.description' defaultMessage='Avatar description' />
</Text>
<Text className='whitespace-pre-wrap'>
{alt}
</Text>
</Stack>
}
isFlush
>
<AltIndicator message={<FormattedMessage id='account.avatar.alt' defaultMessage='Avatar' />} />
</Popover>
);
}
if (isAvatarMissing) {
return (
<div

View File

@ -1,6 +1,7 @@
import React from 'react';
import StillImage from 'pl-fe/components/still-image';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { removeVS16s, toCodePoints } from 'pl-fe/utils/emoji';
import { joinPublicPath } from 'pl-fe/utils/static';
@ -12,6 +13,7 @@ interface IEmoji extends Pick<React.ImgHTMLAttributes<HTMLImageElement>, 'alt' |
/** A single emoji image. */
const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
const { disableUserProvidedMedia } = useSettings();
const { emoji, alt, src, noGroup, ...rest } = props;
let filename;
@ -24,6 +26,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
if (!filename && !src) return null;
if (src) {
if (disableUserProvidedMedia) return <>{alt || emoji}</>;
return (
<StillImage
alt={alt || emoji}
@ -40,7 +43,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
<img
draggable='false'
alt={alt || emoji}
src={src || joinPublicPath(`packs/emoji/${filename}.svg`)}
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
{...rest}
/>
);

View File

@ -3,9 +3,12 @@ import fileCodeIcon from '@tabler/icons/outline/file-code.svg';
import fileSpreadsheetIcon from '@tabler/icons/outline/file-spreadsheet.svg';
import fileTextIcon from '@tabler/icons/outline/file-text.svg';
import fileZipIcon from '@tabler/icons/outline/file-zip.svg';
import audioIcon from '@tabler/icons/outline/music.svg';
import defaultIcon from '@tabler/icons/outline/paperclip.svg';
import editIcon from '@tabler/icons/outline/pencil.svg';
import imageIcon from '@tabler/icons/outline/photo.svg';
import presentationIcon from '@tabler/icons/outline/presentation.svg';
import videoIcon from '@tabler/icons/outline/video.svg';
import xIcon from '@tabler/icons/outline/x.svg';
import zoomInIcon from '@tabler/icons/outline/zoom-in.svg';
import clsx from 'clsx';
@ -55,6 +58,10 @@ const MIMETYPE_ICONS: Record<string, string> = {
'application/x-abiword': fileTextIcon,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTextIcon,
'application/vnd.oasis.opendocument.text': fileTextIcon,
image: imageIcon,
video: videoIcon,
gifv: videoIcon,
audio: audioIcon,
};
const messages = defineMessages({

View File

@ -1,4 +1,5 @@
import { useMutation } from '@tanstack/react-query';
import clsx from 'clsx';
import { GOTOSOCIAL, MASTODON, mediaAttachmentSchema } from 'pl-api';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -9,12 +10,16 @@ import { biteAccount, blockAccount, pinAccount, removeFromFollowers, unblockAcco
import { mentionCompose, directCompose } from 'pl-fe/actions/compose';
import { initReport, ReportableEntities } from 'pl-fe/actions/reports';
import { useFollow } from 'pl-fe/api/hooks/accounts/use-follow';
import AltIndicator from 'pl-fe/components/alt-indicator';
import Badge from 'pl-fe/components/badge';
import DropdownMenu, { Menu } from 'pl-fe/components/dropdown-menu';
import StillImage from 'pl-fe/components/still-image';
import Avatar from 'pl-fe/components/ui/avatar';
import HStack from 'pl-fe/components/ui/hstack';
import IconButton from 'pl-fe/components/ui/icon-button';
import Popover from 'pl-fe/components/ui/popover';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import VerificationBadge from 'pl-fe/components/verification-badge';
import MovedNote from 'pl-fe/features/account-timeline/components/moved-note';
import ActionButton from 'pl-fe/features/ui/components/action-button';
@ -23,11 +28,11 @@ import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useClient } from 'pl-fe/hooks/use-client';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { useChats } from 'pl-fe/queries/chats';
import { queryClient } from 'pl-fe/queries/client';
import { blockDomainMutationOptions, unblockDomainMutationOptions } from 'pl-fe/queries/settings/domain-blocks';
import { useModalsStore } from 'pl-fe/stores/modals';
import { useSettingsStore } from 'pl-fe/stores/settings';
import toast from 'pl-fe/toast';
import { isDefaultHeader } from 'pl-fe/utils/accounts';
import copy from 'pl-fe/utils/copy';
@ -98,7 +103,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
const { account: ownAccount } = useOwnAccount();
const { follow } = useFollow();
const { openModal } = useModalsStore();
const { settings } = useSettingsStore();
const settings = useSettings();
const { software } = features.version;
@ -566,6 +571,29 @@ const Header: React.FC<IHeader> = ({ account }) => {
const renderHeader = () => {
let header: React.ReactNode;
if (settings.disableUserProvidedMedia) {
if (!account.header_description) return null;
else return (
<Popover
interaction='hover'
referenceElementClassName='cursor-pointer'
content={
<Stack space={1} className='max-h-[32rem] max-w-96 overflow-auto p-4'>
<Text weight='semibold'>
<FormattedMessage id='account.header.description' defaultMessage='Header description' />
</Text>
<Text className='whitespace-pre-wrap'>
{account.header_description}
</Text>
</Stack>
}
isFlush
>
<AltIndicator className='ml-6 mt-6 w-fit' message={<FormattedMessage id='account.header.alt' defaultMessage='Header' />} />
</Popover>
);
}
if (account.header) {
header = (
<StillImage
@ -655,7 +683,11 @@ const Header: React.FC<IHeader> = ({ account }) => {
)}
<div>
<div className='relative isolate flex h-32 w-full flex-col justify-center overflow-hidden bg-gray-200 black:rounded-t-none dark:bg-gray-900/50 md:rounded-t-xl lg:h-48'>
<div
className={clsx('relative isolate flex w-full flex-col justify-center overflow-hidden black:rounded-t-none md:rounded-t-xl', {
'h-32 bg-gray-200 dark:bg-gray-900/50 lg:h-48': !settings.disableUserProvidedMedia,
})}
>
{renderHeader()}
<div className='absolute left-2 top-2'>

View File

@ -1,6 +1,7 @@
import split from 'graphemesplit';
import React from 'react';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { makeEmojiMap } from 'pl-fe/utils/normalizers';
import unicodeMapping from './mapping';
@ -34,6 +35,8 @@ interface IEmojify {
}
const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {} }) => {
const { disableUserProvidedMedia } = useSettings();
if (Array.isArray(emojis)) emojis = makeEmojiMap(emojis);
const nodes = [];
@ -76,7 +79,7 @@ const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {} }) => {
nodes.push(
<img key={index} draggable={false} className='emojione transition-transform hover:scale-125' alt={unqualified} title={`:${shortcode}:`} src={`/packs/emoji/${unified}.svg`} />,
);
} else if (c === ':') {
} else if (!disableUserProvidedMedia && c === ':') {
if (!open) {
clearStack();
}

View File

@ -282,6 +282,13 @@ const Preferences = () => {
<SettingToggle settings={settings} settingPath={['checkEmojiReactsSupport']} onChange={onToggleChange} />
</ListItem>
)}
<ListItem
label={<FormattedMessage id='preferences.fields.disable_user_provided_media_label' defaultMessage='Do not display user-provided media' />}
hint={<FormattedMessage id='preferences.fields.disable_user_provided_media_hint' defaultMessage='This will hide images, videos, and other media uploaded by users and display alternative text instead.' />}
>
<SettingToggle settings={settings} settingPath={['disableUserProvidedMedia']} onChange={onToggleChange} />
</ListItem>
</List>
<List>

View File

@ -25,7 +25,7 @@ interface IUserPanel {
const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain }) => {
const intl = useIntl();
const { demetricator } = useSettings();
const { demetricator, disableUserProvidedMedia } = useSettings();
const { account } = useAccount(accountId);
const fqn = useAppSelector((state) => displayFqn(state));
@ -38,26 +38,30 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
<div className='relative'>
<Stack space={2}>
<Stack>
<div className='relative -mx-4 -mt-4 h-24 overflow-hidden bg-gray-200'>
{header && (
<StillImage src={account.header} alt={account.header_description} />
)}
</div>
{!disableUserProvidedMedia && (
<div className='relative -mx-4 -mt-4 h-24 overflow-hidden bg-gray-200'>
{header && (
<StillImage src={account.header} alt={account.header_description} />
)}
</div>
)}
<HStack justifyContent='between'>
<Link
to={`/@${account.acct}`}
title={acct}
className='-mt-12 block'
>
<Avatar
src={account.avatar}
alt={account.avatar_description}
isCat={account.is_cat}
size={80}
className='size-20 bg-gray-50 ring-2 ring-white'
/>
</Link>
<HStack justifyContent={disableUserProvidedMedia ? 'end' : 'between'}>
{!disableUserProvidedMedia && (
<Link
to={`/@${account.acct}`}
title={acct}
className='-mt-12 block'
>
<Avatar
src={account.avatar}
alt={account.avatar_description}
isCat={account.is_cat}
size={80}
className='size-20 bg-gray-50 ring-2 ring-white'
/>
</Link>
)}
{action && (
<div className='mt-2'>{action}</div>

View File

@ -4,6 +4,7 @@
"accordion.expand": "Expand",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.avatar.alt": "Avatar",
"account.avatar.description": "Avatar description",
"account.badges.bot": "Bot",
"account.birthday": "Born {date}",
"account.birthday_today": "Birthday is today!",
@ -31,6 +32,7 @@
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.follows_you": "Follows you",
"account.header.alt": "Profile header",
"account.header.description": "Header description",
"account.hide_reblogs": "Hide reposts from @{name}",
"account.last_status": "Last active",
"account.link_verified_on": "Ownership of this link was checked on {date}",
@ -657,12 +659,14 @@
"edit_federation.success": "{host} federation was updated",
"edit_federation.unlisted": "Force posts unlisted",
"edit_password.header": "Change password",
"edit_profile.custom_css.remaining_characters": "{remaining, plural, one {# character} other {# characters}} remaining",
"edit_profile.error": "Profile update failed",
"edit_profile.fields.bio_label": "Bio",
"edit_profile.fields.bio_placeholder": "Tell us about yourself.",
"edit_profile.fields.birthday_label": "Birthday",
"edit_profile.fields.birthday_placeholder": "Your birthday",
"edit_profile.fields.bot_label": "This is a bot account",
"edit_profile.fields.custom_css_label": "Custom CSS",
"edit_profile.fields.discoverable_label": "Allow account discovery",
"edit_profile.fields.display_name_label": "Display name",
"edit_profile.fields.display_name_placeholder": "Name",
@ -681,6 +685,11 @@
"edit_profile.fields.rss_label": "Enable RSS feed for public posts",
"edit_profile.fields.speak_as_cat_label": "The user speaks as a cat",
"edit_profile.fields.stranger_notifications_label": "Block notifications from strangers",
"edit_profile.fields.web_layout.gallery": "Media-only gallery layout",
"edit_profile.fields.web_layout.microblog": "Classic microblog layout",
"edit_profile.fields.web_visibility.none": "Show no posts",
"edit_profile.fields.web_visibility.public": "Show public posts only",
"edit_profile.fields.web_visibility.unlisted": "Show public and unlisted posts",
"edit_profile.header": "Edit profile",
"edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored",
"edit_profile.hints.discoverable": "Display account in profile directory and allow indexing by external services",
@ -1068,6 +1077,11 @@
"manage_group.fields.name_placeholder": "Group Name",
"manage_group.pending_requests": "Pending requests",
"media-gallery.description": "Image description",
"media.default_description.attachment": "Attachment",
"media.default_description.audio": "Audio",
"media.default_description.gifv": "GIFV",
"media.default_description.image": "Image",
"media.default_description.video": "Video",
"media_panel.empty_message": "No media found.",
"media_panel.title": "Media",
"mfa.confirm.success_message": "MFA confirmed",
@ -1292,6 +1306,8 @@
"preferences.fields.demetricator_label": "Hide social media counters",
"preferences.fields.demo_hint": "Use the default pl-fe logo and color scheme. Useful for taking screenshots.",
"preferences.fields.demo_label": "Demo mode",
"preferences.fields.disable_user_provided_media_hint": "This will hide images, videos, and other media uploaded by users and display alternative text instead.",
"preferences.fields.disable_user_provided_media_label": "Do not display user-provided media",
"preferences.fields.display_media.default": "Hide posts marked as sensitive",
"preferences.fields.display_media.hide_all": "Always hide media posts",
"preferences.fields.display_media.show_all": "Always show posts",
@ -1310,6 +1326,8 @@
"preferences.fields.theme_reset": "Reset theme",
"preferences.fields.underline_links_label": "Always underline links in posts",
"preferences.fields.unfollow_modal_label": "Show confirmation dialog before unfollowing someone",
"preferences.fields.web_layout_label": "Layout of the web view of your profile",
"preferences.fields.web_visibility_label": "Visibility level of posts displayed on your profile",
"preferences.fields.wrench_label": "Display wrench reaction button",
"preferences.hints.demetricator": "Decrease social media anxiety by hiding all numbers from the site.",
"preferences.hints.mention_policy": "Applies to direct messages and public posts",

View File

@ -49,6 +49,7 @@ const settingsSchema = v.object({
redirectServices: v.fallback(v.record(v.string(), v.string()), {}),
}),
checkEmojiReactsSupport: v.fallback(v.boolean(), false),
disableUserProvidedMedia: v.fallback(v.boolean(), false),
theme: v.fallback(v.optional(v.object({
brandColor: v.fallback(v.string(), ''),