Merge remote-tracking branch 'origin/develop' into chats
This commit is contained in:
commit
c9424cda76
21 changed files with 428 additions and 310 deletions
|
@ -1,6 +1,5 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
@ -13,8 +12,6 @@ import { truncateFilename } from 'soapbox/utils/media';
|
|||
import { isIOS } from '../is_mobile';
|
||||
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio';
|
||||
|
||||
import { Button, Text } from './ui';
|
||||
|
||||
import type { Property } from 'csstype';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
|
@ -39,10 +36,6 @@ interface SizeData {
|
|||
width: number,
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' },
|
||||
});
|
||||
|
||||
const withinLimits = (aspectRatio: number) => {
|
||||
return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio;
|
||||
};
|
||||
|
@ -276,35 +269,16 @@ interface IMediaGallery {
|
|||
const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||
const {
|
||||
media,
|
||||
sensitive = false,
|
||||
defaultWidth = 0,
|
||||
onToggleVisibility,
|
||||
onOpenMedia,
|
||||
cacheWidth,
|
||||
compact,
|
||||
height,
|
||||
} = props;
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const settings = useSettings();
|
||||
const displayMedia = settings.get('displayMedia') as string | undefined;
|
||||
|
||||
const [visible, setVisible] = useState<boolean>(props.visible !== undefined ? props.visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all'));
|
||||
const [width, setWidth] = useState<number>(defaultWidth);
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleOpen: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onToggleVisibility) {
|
||||
onToggleVisibility();
|
||||
} else {
|
||||
setVisible(!visible);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
onOpenMedia(media, index);
|
||||
};
|
||||
|
@ -545,20 +519,13 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
|||
index={i}
|
||||
size={sizeData.size}
|
||||
displayWidth={sizeData.width}
|
||||
visible={visible}
|
||||
visible={!!props.visible}
|
||||
dimensions={sizeData.itemsDimensions[i]}
|
||||
last={i === ATTACHMENT_LIMIT - 1}
|
||||
total={media.size}
|
||||
/>
|
||||
));
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (node.current) {
|
||||
|
@ -572,60 +539,8 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
|||
}
|
||||
}, [node.current]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(!!props.visible);
|
||||
}, [props.visible]);
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery', { 'media-gallery--compact': compact })} style={sizeData.style} ref={node}>
|
||||
<div
|
||||
className={classNames({
|
||||
'absolute z-40': true,
|
||||
'inset-0': !visible && !compact,
|
||||
'left-1 top-1': visible || compact,
|
||||
})}
|
||||
>
|
||||
{sensitive && (
|
||||
(visible || compact) ? (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.toggle_visible)}
|
||||
icon={visible ? require('@tabler/icons/eye-off.svg') : require('@tabler/icons/eye.svg')}
|
||||
onClick={handleOpen}
|
||||
theme='transparent'
|
||||
size='sm'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={
|
||||
classNames({
|
||||
'bg-gray-800/75 cursor-default backdrop-blur-sm rounded-lg w-full h-full border-0 flex items-center justify-center': true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className='text-center w-3/4 mx-auto space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>{warning}</Text>
|
||||
<Text size='sm'>
|
||||
<FormattedMessage id='status.sensitive_warning.subtitle' defaultMessage='This content may not be suitable for all audiences.' />
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<FormattedMessage id='status.sensitive_warning.action' defaultMessage='Show content' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -30,7 +30,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
|||
muted = false,
|
||||
onClick,
|
||||
showMedia = true,
|
||||
onToggleVisibility = () => {},
|
||||
onToggleVisibility = () => { },
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [mediaWrapperWidth, setMediaWrapperWidth] = useState<number | undefined>(undefined);
|
||||
|
|
|
@ -18,8 +18,8 @@ import StatusActionBar from './status-action-bar';
|
|||
import StatusMedia from './status-media';
|
||||
import StatusReplyMentions from './status-reply-mentions';
|
||||
import StatusContent from './status_content';
|
||||
import ModerationOverlay from './statuses/moderation-overlay';
|
||||
import { Card, HStack, Text } from './ui';
|
||||
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
|
||||
import { Card, HStack, Stack, Text } from './ui';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import type {
|
||||
|
@ -301,6 +301,7 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
const accountAction = props.accountAction || reblogElement;
|
||||
|
||||
const inReview = status.visibility === 'self';
|
||||
const isSensitive = status.sensitive;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers} data-testid='status'>
|
||||
|
@ -351,43 +352,51 @@ const Status: React.FC<IStatus> = (props) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames('status__content-wrapper relative', {
|
||||
'min-h-[220px]': inReview,
|
||||
})}
|
||||
>
|
||||
{inReview ? (
|
||||
<ModerationOverlay />
|
||||
) : null}
|
||||
<div className='status__content-wrapper'>
|
||||
<Stack
|
||||
className={
|
||||
classNames('relative', {
|
||||
'min-h-[220px]': inReview || isSensitive,
|
||||
})
|
||||
}
|
||||
>
|
||||
{(inReview || isSensitive) ? (
|
||||
<SensitiveContentOverlay
|
||||
status={status}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!group && actualStatus.group && (
|
||||
<div className='status__meta'>
|
||||
Posted in <NavLink to={`/groups/${actualStatus.getIn(['group', 'id'])}`}>{String(actualStatus.getIn(['group', 'title']))}</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{!group && actualStatus.group && (
|
||||
<div className='status__meta'>
|
||||
Posted in <NavLink to={`/groups/${actualStatus.getIn(['group', 'id'])}`}>{String(actualStatus.getIn(['group', 'title']))}</NavLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusReplyMentions
|
||||
status={actualStatus}
|
||||
hoverable={hoverable}
|
||||
/>
|
||||
<StatusReplyMentions
|
||||
status={actualStatus}
|
||||
hoverable={hoverable}
|
||||
/>
|
||||
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
onClick={handleClick}
|
||||
expanded={!status.hidden}
|
||||
onExpandedToggle={handleExpandedToggle}
|
||||
collapsable
|
||||
/>
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
onClick={handleClick}
|
||||
expanded={!status.hidden}
|
||||
onExpandedToggle={handleExpandedToggle}
|
||||
collapsable
|
||||
/>
|
||||
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
muted={muted}
|
||||
onClick={handleClick}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
/>
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
muted={muted}
|
||||
onClick={handleClick}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={handleToggleMediaVisibility}
|
||||
/>
|
||||
|
||||
{quote}
|
||||
{quote}
|
||||
</Stack>
|
||||
|
||||
{!hideActionBar && (
|
||||
<div className='pt-4'>
|
||||
|
|
|
@ -19,7 +19,7 @@ import useAds from 'soapbox/queries/ads';
|
|||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
import type { IScrollableList } from 'soapbox/components/scrollable_list';
|
||||
import type { Ad as AdEntity } from 'soapbox/features/ads/providers';
|
||||
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
|
||||
|
||||
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||
/** Unique key to preserve the scroll position when navigating back. */
|
||||
|
@ -141,12 +141,7 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
|
||||
const renderAd = (ad: AdEntity, index: number) => {
|
||||
return (
|
||||
<Ad
|
||||
key={`ad-${index}`}
|
||||
card={ad.card}
|
||||
impression={ad.impression}
|
||||
expires={ad.expires}
|
||||
/>
|
||||
<Ad key={`ad-${index}`} ad={ad} />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../jest/test-helpers';
|
||||
import ModerationOverlay from '../moderation-overlay';
|
||||
|
||||
describe('<ModerationOverlay />', () => {
|
||||
it('defaults to enabled', () => {
|
||||
render(<ModerationOverlay />);
|
||||
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Content Under Review');
|
||||
});
|
||||
|
||||
it('can be toggled', () => {
|
||||
render(<ModerationOverlay />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('moderation-overlay')).not.toHaveTextContent('Content Under Review');
|
||||
expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Hide');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,111 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { ReducerStatus } from 'soapbox/reducers/statuses';
|
||||
|
||||
import { fireEvent, render, rootState, screen } from '../../../jest/test-helpers';
|
||||
import SensitiveContentOverlay from '../sensitive-content-overlay';
|
||||
|
||||
describe('<SensitiveContentOverlay />', () => {
|
||||
let status: ReducerStatus;
|
||||
|
||||
describe('when the Status is marked as sensitive', () => {
|
||||
beforeEach(() => {
|
||||
status = normalizeStatus({ sensitive: true }) as ReducerStatus;
|
||||
});
|
||||
|
||||
it('displays the "Sensitive content" warning', () => {
|
||||
render(<SensitiveContentOverlay status={status} />);
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content');
|
||||
});
|
||||
|
||||
it('can be toggled', () => {
|
||||
render(<SensitiveContentOverlay status={status} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content');
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content');
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the Status is marked as in review', () => {
|
||||
beforeEach(() => {
|
||||
status = normalizeStatus({ visibility: 'self', sensitive: false }) as ReducerStatus;
|
||||
});
|
||||
|
||||
it('displays the "Under review" warning', () => {
|
||||
render(<SensitiveContentOverlay status={status} />);
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
|
||||
});
|
||||
|
||||
it('can be toggled', () => {
|
||||
render(<SensitiveContentOverlay status={status} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Content Under Review');
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the Status is marked as in review and sensitive', () => {
|
||||
beforeEach(() => {
|
||||
status = normalizeStatus({ visibility: 'self', sensitive: true }) as ReducerStatus;
|
||||
});
|
||||
|
||||
it('displays the "Under review" warning', () => {
|
||||
render(<SensitiveContentOverlay status={status} />);
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
|
||||
});
|
||||
|
||||
it('can be toggled', () => {
|
||||
render(<SensitiveContentOverlay status={status} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Content Under Review');
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review');
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the Status is marked as sensitive and displayMedia set to "show_all"', () => {
|
||||
let store: any;
|
||||
|
||||
beforeEach(() => {
|
||||
status = normalizeStatus({ sensitive: true }) as ReducerStatus;
|
||||
store = rootState
|
||||
.set('settings', ImmutableMap({
|
||||
displayMedia: 'show_all',
|
||||
}));
|
||||
});
|
||||
|
||||
it('displays the "Under review" warning', () => {
|
||||
render(<SensitiveContentOverlay status={status} />, undefined, store);
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content');
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
|
||||
});
|
||||
|
||||
it('can be toggled', () => {
|
||||
render(<SensitiveContentOverlay status={status} />, undefined, store);
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content');
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Hide');
|
||||
|
||||
fireEvent.click(screen.getByTestId('button'));
|
||||
expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content');
|
||||
expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,93 +0,0 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||
|
||||
import { Button, HStack, Text } from '../ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide' },
|
||||
title: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' },
|
||||
subtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' },
|
||||
contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' },
|
||||
show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' },
|
||||
});
|
||||
|
||||
const ModerationOverlay = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { links } = useSoapboxConfig();
|
||||
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
const toggleVisibility = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
setVisible((prevValue) => !prevValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('absolute z-40', {
|
||||
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
|
||||
'bg-gray-800/75 inset-0': !visible,
|
||||
'top-1 left-1': visible,
|
||||
})}
|
||||
data-testid='moderation-overlay'
|
||||
>
|
||||
{visible ? (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.hide)}
|
||||
icon={require('@tabler/icons/eye-off.svg')}
|
||||
onClick={toggleVisibility}
|
||||
theme='transparent'
|
||||
size='sm'
|
||||
/>
|
||||
) : (
|
||||
<div className='text-center w-3/4 mx-auto space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>
|
||||
{intl.formatMessage(messages.title)}
|
||||
</Text>
|
||||
|
||||
<Text theme='white' size='sm' weight='medium'>
|
||||
{intl.formatMessage(messages.subtitle)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<HStack alignItems='center' justifyContent='center' space={2}>
|
||||
{links.get('support') && (
|
||||
<a
|
||||
href={links.get('support')}
|
||||
target='_blank'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/headset.svg')}
|
||||
>
|
||||
{intl.formatMessage(messages.contact)}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={toggleVisibility}
|
||||
>
|
||||
{intl.formatMessage(messages.show)}
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerationOverlay;
|
124
app/soapbox/components/statuses/sensitive-content-overlay.tsx
Normal file
124
app/soapbox/components/statuses/sensitive-content-overlay.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { defaultMediaVisibility } from 'soapbox/utils/status';
|
||||
|
||||
import { Button, HStack, Text } from '../ui';
|
||||
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide content' },
|
||||
sensitiveTitle: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' },
|
||||
underReviewTitle: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' },
|
||||
underReviewSubtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' },
|
||||
sensitiveSubtitle: { id: 'status.sensitive_warning.subtitle', defaultMessage: 'This content may not be suitable for all audiences.' },
|
||||
contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' },
|
||||
show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' },
|
||||
});
|
||||
|
||||
interface ISensitiveContentOverlay {
|
||||
status: StatusEntity
|
||||
onToggleVisibility?(): void
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const SensitiveContentOverlay = (props: ISensitiveContentOverlay) => {
|
||||
const { onToggleVisibility, status } = props;
|
||||
const isUnderReview = status.visibility === 'self';
|
||||
|
||||
const settings = useSettings();
|
||||
const displayMedia = settings.get('displayMedia') as string;
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const { links } = useSoapboxConfig();
|
||||
|
||||
const [visible, setVisible] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
|
||||
|
||||
const toggleVisibility = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (onToggleVisibility) {
|
||||
onToggleVisibility();
|
||||
} else {
|
||||
setVisible((prevValue) => !prevValue);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof props.visible !== 'undefined') {
|
||||
setVisible(!!props.visible);
|
||||
}
|
||||
}, [props.visible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('absolute z-40', {
|
||||
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center items-center': !visible,
|
||||
'bg-gray-800/75 inset-0': !visible,
|
||||
'bottom-1 right-1': visible,
|
||||
})}
|
||||
data-testid='sensitive-overlay'
|
||||
>
|
||||
{visible ? (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.hide)}
|
||||
icon={require('@tabler/icons/eye-off.svg')}
|
||||
onClick={toggleVisibility}
|
||||
theme='primary'
|
||||
size='sm'
|
||||
/>
|
||||
) : (
|
||||
<div className='text-center w-3/4 mx-auto space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<Text theme='white' weight='semibold'>
|
||||
{intl.formatMessage(isUnderReview ? messages.underReviewTitle : messages.sensitiveTitle)}
|
||||
</Text>
|
||||
|
||||
<Text theme='white' size='sm' weight='medium'>
|
||||
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<HStack alignItems='center' justifyContent='center' space={2}>
|
||||
{isUnderReview ? (
|
||||
<>
|
||||
{links.get('support') && (
|
||||
<a
|
||||
href={links.get('support')}
|
||||
target='_blank'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/headset.svg')}
|
||||
>
|
||||
{intl.formatMessage(messages.contact)}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
theme='outline'
|
||||
size='sm'
|
||||
icon={require('@tabler/icons/eye.svg')}
|
||||
onClick={toggleVisibility}
|
||||
>
|
||||
{intl.formatMessage(messages.show)}
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SensitiveContentOverlay;
|
|
@ -16,6 +16,7 @@ const spaces = {
|
|||
|
||||
const justifyContentOptions = {
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
};
|
||||
|
||||
const alignItemsOptions = {
|
||||
|
@ -29,7 +30,7 @@ interface IStack extends React.HTMLAttributes<HTMLDivElement> {
|
|||
/** Horizontal alignment of children. */
|
||||
alignItems?: 'center' | 'start',
|
||||
/** Vertical alignment of children. */
|
||||
justifyContent?: 'center'
|
||||
justifyContent?: keyof typeof justifyContentOptions
|
||||
/** Extra class names on the <div> element. */
|
||||
className?: string
|
||||
/** Whether to let the flexbox grow. */
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -8,19 +8,14 @@ import StatusCard from 'soapbox/features/status/components/card';
|
|||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { AdKeys } from 'soapbox/queries/ads';
|
||||
|
||||
import type { Card as CardEntity } from 'soapbox/types/entities';
|
||||
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
|
||||
|
||||
interface IAd {
|
||||
/** Embedded ad data in Card format (almost like OEmbed). */
|
||||
card: CardEntity,
|
||||
/** Impression URL to fetch upon display. */
|
||||
impression?: string,
|
||||
/** Time when the ad expires and should no longer be displayed. */
|
||||
expires?: Date,
|
||||
ad: AdEntity,
|
||||
}
|
||||
|
||||
/** Displays an ad in sponsored post format. */
|
||||
const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
||||
const Ad: React.FC<IAd> = ({ ad }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const instance = useAppSelector(state => state.instance);
|
||||
|
||||
|
@ -28,6 +23,14 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
|||
const infobox = useRef<HTMLDivElement>(null);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
// Fetch the impression URL (if any) upon displaying the ad.
|
||||
// Don't fetch it more than once.
|
||||
useQuery(['ads', 'impression', ad.impression], () => {
|
||||
if (ad.impression) {
|
||||
return fetch(ad.impression);
|
||||
}
|
||||
}, { cacheTime: Infinity, staleTime: Infinity });
|
||||
|
||||
/** Invalidate query cache for ads. */
|
||||
const bustCache = (): void => {
|
||||
queryClient.invalidateQueries(AdKeys.ads);
|
||||
|
@ -54,18 +57,10 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
|||
};
|
||||
}, [infobox]);
|
||||
|
||||
// Fetch the impression URL (if any) upon displaying the ad.
|
||||
// It's common for ad providers to provide this.
|
||||
useEffect(() => {
|
||||
if (impression) {
|
||||
fetch(impression);
|
||||
}
|
||||
}, [impression]);
|
||||
|
||||
// Wait until the ad expires, then invalidate cache.
|
||||
useEffect(() => {
|
||||
if (expires) {
|
||||
const delta = expires.getTime() - (new Date()).getTime();
|
||||
if (ad.expires_at) {
|
||||
const delta = new Date(ad.expires_at).getTime() - (new Date()).getTime();
|
||||
timer.current = setTimeout(bustCache, delta);
|
||||
}
|
||||
|
||||
|
@ -74,7 +69,7 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
|||
clearTimeout(timer.current);
|
||||
}
|
||||
};
|
||||
}, [expires]);
|
||||
}, [ad.expires_at]);
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
|
@ -113,7 +108,7 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
|||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<StatusCard card={card} onOpenMedia={() => { }} horizontal />
|
||||
<StatusCard card={ad.card} onOpenMedia={() => { }} horizontal />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
|
@ -126,11 +121,15 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
|
|||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted'>
|
||||
<FormattedMessage
|
||||
id='sponsored.info.message'
|
||||
defaultMessage='{siteTitle} displays ads to help fund our service.'
|
||||
values={{ siteTitle: instance.title }}
|
||||
/>
|
||||
{ad.reason ? (
|
||||
ad.reason
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='sponsored.info.message'
|
||||
defaultMessage='{siteTitle} displays ads to help fund our service.'
|
||||
values={{ siteTitle: instance.title }}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
|
|
@ -7,6 +7,7 @@ import type { Card } from 'soapbox/types/entities';
|
|||
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
|
||||
soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
|
||||
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default,
|
||||
truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default,
|
||||
};
|
||||
|
||||
/** Ad server implementation. */
|
||||
|
@ -21,7 +22,9 @@ interface Ad {
|
|||
/** Impression URL to fetch when displaying the ad. */
|
||||
impression?: string,
|
||||
/** Time when the ad expires and should no longer be displayed. */
|
||||
expires?: Date,
|
||||
expires_at?: string,
|
||||
/** Reason the ad is displayed. */
|
||||
reason?: string,
|
||||
}
|
||||
|
||||
/** Gets the current provider based on config. */
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
import { normalizeCard } from 'soapbox/normalizers';
|
||||
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
|
||||
|
||||
import type { AdProvider } from '.';
|
||||
|
||||
|
@ -36,14 +36,14 @@ const RumbleAdProvider: AdProvider = {
|
|||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as RumbleApiResponse;
|
||||
return data.ads.map(item => ({
|
||||
return data.ads.map(item => normalizeAd({
|
||||
impression: item.impression,
|
||||
card: normalizeCard({
|
||||
type: item.type === 1 ? 'link' : 'rich',
|
||||
image: item.asset,
|
||||
url: item.click,
|
||||
}),
|
||||
expires: new Date(item.expires * 1000),
|
||||
expires_at: new Date(item.expires * 1000),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
39
app/soapbox/features/ads/providers/truth.ts
Normal file
39
app/soapbox/features/ads/providers/truth.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { normalizeCard } from 'soapbox/normalizers';
|
||||
|
||||
import type { AdProvider } from '.';
|
||||
import type { Card } from 'soapbox/types/entities';
|
||||
|
||||
/** TruthSocial ad API entity. */
|
||||
interface TruthAd {
|
||||
impression: string,
|
||||
card: Card,
|
||||
expires_at: string,
|
||||
reason: string,
|
||||
}
|
||||
|
||||
/** Provides ads from the TruthSocial API. */
|
||||
const TruthAdProvider: AdProvider = {
|
||||
getAds: async(getState) => {
|
||||
const state = getState();
|
||||
const settings = getSettings(state);
|
||||
|
||||
const response = await fetch('/api/v2/truth/ads?device=desktop', {
|
||||
headers: {
|
||||
'Accept-Language': settings.get('locale', '*') as string,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as TruthAd[];
|
||||
return data.map(item => ({
|
||||
...item,
|
||||
card: normalizeCard(item.card),
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
export default TruthAdProvider;
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from 'clsx';
|
||||
import React, { useRef } from 'react';
|
||||
import { FormattedDate, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
|
@ -5,7 +6,8 @@ import Icon from 'soapbox/components/icon';
|
|||
import StatusMedia from 'soapbox/components/status-media';
|
||||
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
|
||||
import StatusContent from 'soapbox/components/status_content';
|
||||
import { HStack, Text } from 'soapbox/components/ui';
|
||||
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay';
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
|
||||
import { getActualStatus } from 'soapbox/utils/status';
|
||||
|
@ -48,6 +50,9 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
const { account } = actualStatus;
|
||||
if (!account || typeof account !== 'object') return null;
|
||||
|
||||
const isUnderReview = actualStatus.visibility === 'self';
|
||||
const isSensitive = actualStatus.sensitive;
|
||||
|
||||
let statusTypeIcon = null;
|
||||
|
||||
let quote;
|
||||
|
@ -85,19 +90,35 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
|
||||
<StatusReplyMentions status={actualStatus} />
|
||||
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
expanded={!actualStatus.hidden}
|
||||
onExpandedToggle={handleExpandedToggle}
|
||||
/>
|
||||
<Stack
|
||||
className={
|
||||
classNames('relative', {
|
||||
'min-h-[220px]': isUnderReview || isSensitive,
|
||||
})
|
||||
}
|
||||
>
|
||||
{(isUnderReview || isSensitive) ? (
|
||||
<SensitiveContentOverlay
|
||||
status={status}
|
||||
visible={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
<StatusContent
|
||||
status={actualStatus}
|
||||
expanded={!actualStatus.hidden}
|
||||
onExpandedToggle={handleExpandedToggle}
|
||||
/>
|
||||
|
||||
{quote}
|
||||
<StatusMedia
|
||||
status={actualStatus}
|
||||
showMedia={showMedia}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
/>
|
||||
|
||||
{quote}
|
||||
</Stack>
|
||||
|
||||
<HStack justifyContent='between' alignItems='center' className='py-2' wrap>
|
||||
<StatusInteractionBar status={actualStatus} />
|
||||
|
|
|
@ -29,7 +29,6 @@ import MissingIndicator from 'soapbox/components/missing_indicator';
|
|||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import StatusActionBar from 'soapbox/components/status-action-bar';
|
||||
import ModerationOverlay from 'soapbox/components/statuses/moderation-overlay';
|
||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||
import Tombstone from 'soapbox/components/tombstone';
|
||||
import { Column, Stack } from 'soapbox/components/ui';
|
||||
|
@ -135,7 +134,6 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
const me = useAppSelector(state => state.me);
|
||||
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
|
||||
const displayMedia = settings.get('displayMedia') as DisplayMedia;
|
||||
const inReview = status?.visibility === 'self';
|
||||
|
||||
const { ancestorsIds, descendantsIds } = useAppSelector(state => {
|
||||
let ancestorsIds = ImmutableOrderedSet<string>();
|
||||
|
@ -156,7 +154,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
};
|
||||
});
|
||||
|
||||
const [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
|
||||
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
||||
const [next, setNext] = useState<string>();
|
||||
|
||||
|
@ -165,7 +163,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
const scroller = useRef<VirtuosoHandle>(null);
|
||||
|
||||
/** Fetch the status (and context) from the API. */
|
||||
const fetchData = async() => {
|
||||
const fetchData = async () => {
|
||||
const { params } = props;
|
||||
const { statusId } = params;
|
||||
const { next } = await dispatch(fetchStatusWithContext(statusId));
|
||||
|
@ -393,7 +391,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
|
||||
// Reset media visibility if status changes.
|
||||
useEffect(() => {
|
||||
setShowMedia(defaultMediaVisibility(status, displayMedia));
|
||||
setShowMedia(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
||||
}, [status?.id]);
|
||||
|
||||
// Scroll focused status into view when thread updates.
|
||||
|
@ -461,18 +459,11 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
<HotKeys handlers={handlers}>
|
||||
<div
|
||||
ref={statusRef}
|
||||
className={
|
||||
classNames('detailed-status__wrapper focusable relative', {
|
||||
'min-h-[220px]': inReview,
|
||||
})
|
||||
}
|
||||
className='detailed-status__wrapper focusable relative'
|
||||
tabIndex={0}
|
||||
// FIXME: no "reblogged by" text is added for the screen reader
|
||||
aria-label={textForScreenReader(intl, status)}
|
||||
>
|
||||
{inReview ? (
|
||||
<ModerationOverlay />
|
||||
) : null}
|
||||
|
||||
<DetailedStatus
|
||||
status={status}
|
||||
|
|
|
@ -25,7 +25,13 @@ export const ChatMessageRecord = ImmutableRecord({
|
|||
});
|
||||
|
||||
const normalizeMedia = (status: ImmutableMap<string, any>) => {
|
||||
return status.update('attachment', null, normalizeAttachment);
|
||||
const attachment = status.get('attachment');
|
||||
|
||||
if (attachment) {
|
||||
return status.set('attachment', normalizeAttachment(attachment));
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
||||
|
|
|
@ -6,15 +6,23 @@ import {
|
|||
|
||||
import { CardRecord, normalizeCard } from '../card';
|
||||
|
||||
export const AdRecord = ImmutableRecord({
|
||||
import type { Ad } from 'soapbox/features/ads/providers';
|
||||
|
||||
export const AdRecord = ImmutableRecord<Ad>({
|
||||
card: CardRecord(),
|
||||
impression: undefined as string | undefined,
|
||||
expires: undefined as Date | undefined,
|
||||
expires_at: undefined as string | undefined,
|
||||
reason: undefined as string | undefined,
|
||||
});
|
||||
|
||||
/** Normalizes an ad from Soapbox Config. */
|
||||
export const normalizeAd = (ad: Record<string, any>) => {
|
||||
const map = ImmutableMap<string, any>(fromJS(ad));
|
||||
const card = normalizeCard(map.get('card'));
|
||||
return AdRecord(map.set('card', card));
|
||||
const expiresAt = map.get('expires_at') || map.get('expires');
|
||||
|
||||
return AdRecord(map.merge({
|
||||
card,
|
||||
expires_at: expiresAt,
|
||||
}));
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
|
||||
import { Ad, getProvider } from 'soapbox/features/ads/providers';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { normalizeAd } from 'soapbox/normalizers';
|
||||
import { isExpired } from 'soapbox/utils/ads';
|
||||
|
||||
const AdKeys = {
|
||||
|
@ -27,7 +28,7 @@ function useAds() {
|
|||
});
|
||||
|
||||
// Filter out expired ads.
|
||||
const data = result.data?.filter(ad => !isExpired(ad));
|
||||
const data = result.data?.map(normalizeAd).filter(ad => !isExpired(ad));
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { normalizeCard } from 'soapbox/normalizers';
|
||||
import { normalizeAd } from 'soapbox/normalizers';
|
||||
|
||||
import { isExpired } from '../ads';
|
||||
|
||||
|
@ -10,13 +10,14 @@ const fiveMins = 5 * 60 * 1000;
|
|||
|
||||
test('isExpired()', () => {
|
||||
const now = new Date();
|
||||
const card = normalizeCard({});
|
||||
const iso = now.toISOString();
|
||||
const epoch = now.getTime();
|
||||
|
||||
// Sanity tests.
|
||||
expect(isExpired({ expires: now, card })).toBe(true);
|
||||
expect(isExpired({ expires: new Date(now.getTime() + 999999), card })).toBe(false);
|
||||
expect(isExpired(normalizeAd({ expires_at: iso }))).toBe(true);
|
||||
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false);
|
||||
|
||||
// Testing the 5-minute mark.
|
||||
expect(isExpired({ expires: new Date(now.getTime() + threeMins), card }, fiveMins)).toBe(true);
|
||||
expect(isExpired({ expires: new Date(now.getTime() + fiveMins + 1000), card }, fiveMins)).toBe(false);
|
||||
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true);
|
||||
expect(isExpired(normalizeAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false);
|
||||
});
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import type { Ad } from 'soapbox/features/ads/providers';
|
||||
import type { Ad } from 'soapbox/types/soapbox';
|
||||
|
||||
/** Time (ms) window to not display an ad if it's about to expire. */
|
||||
const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000;
|
||||
|
||||
/** Whether the ad is expired or about to expire. */
|
||||
const isExpired = (ad: Ad, threshold = AD_EXPIRY_THRESHOLD): boolean => {
|
||||
if (ad.expires) {
|
||||
if (ad.expires_at) {
|
||||
const now = new Date();
|
||||
return now.getTime() > (ad.expires.getTime() - threshold);
|
||||
return now.getTime() > (new Date(ad.expires_at).getTime() - threshold);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,12 @@ export const defaultMediaVisibility = (status: StatusEntity | undefined | null,
|
|||
status = status.reblog;
|
||||
}
|
||||
|
||||
const isUnderReview = status.visibility === 'self';
|
||||
|
||||
if (isUnderReview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all');
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue