diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx index 4f672865b0..f899d7ba41 100644 --- a/app/soapbox/components/media_gallery.tsx +++ b/app/soapbox/components/media_gallery.tsx @@ -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 = (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(props.visible !== undefined ? props.visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all')); const [width, setWidth] = useState(defaultWidth); const node = useRef(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 = (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 = ; - } else { - warning = ; - } useEffect(() => { if (node.current) { @@ -572,60 +539,8 @@ const MediaGallery: React.FC = (props) => { } }, [node.current]); - useEffect(() => { - setVisible(!!props.visible); - }, [props.visible]); - return (
-
- {sensitive && ( - (visible || compact) ? ( - -
-
- ) - )} - - {children} ); diff --git a/app/soapbox/components/status-media.tsx b/app/soapbox/components/status-media.tsx index 0052a95646..4445e67b52 100644 --- a/app/soapbox/components/status-media.tsx +++ b/app/soapbox/components/status-media.tsx @@ -30,7 +30,7 @@ const StatusMedia: React.FC = ({ muted = false, onClick, showMedia = true, - onToggleVisibility = () => {}, + onToggleVisibility = () => { }, }) => { const dispatch = useAppDispatch(); const [mediaWrapperWidth, setMediaWrapperWidth] = useState(undefined); diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 27b1731bd9..35827020f0 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -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 = (props) => { const accountAction = props.accountAction || reblogElement; const inReview = status.visibility === 'self'; + const isSensitive = status.sensitive; return ( @@ -351,43 +352,51 @@ const Status: React.FC = (props) => { /> -
- {inReview ? ( - - ) : null} +
+ + {(inReview || isSensitive) ? ( + + ) : null} - {!group && actualStatus.group && ( -
- Posted in {String(actualStatus.getIn(['group', 'title']))} -
- )} + {!group && actualStatus.group && ( +
+ Posted in {String(actualStatus.getIn(['group', 'title']))} +
+ )} - + - + - + - {quote} + {quote} +
{!hideActionBar && (
diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 3bee7a03ab..09fbe3275a 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -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 { /** Unique key to preserve the scroll position when navigating back. */ @@ -141,12 +141,7 @@ const StatusList: React.FC = ({ const renderAd = (ad: AdEntity, index: number) => { return ( - + ); }; diff --git a/app/soapbox/components/statuses/__tests__/moderation-overlay.test.tsx b/app/soapbox/components/statuses/__tests__/moderation-overlay.test.tsx deleted file mode 100644 index a94923c558..0000000000 --- a/app/soapbox/components/statuses/__tests__/moderation-overlay.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { fireEvent, render, screen } from '../../../jest/test-helpers'; -import ModerationOverlay from '../moderation-overlay'; - -describe('', () => { - it('defaults to enabled', () => { - render(); - expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Content Under Review'); - }); - - it('can be toggled', () => { - render(); - - fireEvent.click(screen.getByTestId('button')); - expect(screen.getByTestId('moderation-overlay')).not.toHaveTextContent('Content Under Review'); - expect(screen.getByTestId('moderation-overlay')).toHaveTextContent('Hide'); - }); -}); diff --git a/app/soapbox/components/statuses/__tests__/sensitive-content-overlay.test.tsx b/app/soapbox/components/statuses/__tests__/sensitive-content-overlay.test.tsx new file mode 100644 index 0000000000..4b2823840c --- /dev/null +++ b/app/soapbox/components/statuses/__tests__/sensitive-content-overlay.test.tsx @@ -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('', () => { + 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(); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Sensitive content'); + }); + + it('can be toggled', () => { + render(); + + 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(); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review'); + }); + + it('can be toggled', () => { + render(); + + 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(); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Content Under Review'); + }); + + it('can be toggled', () => { + render(); + + 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(, undefined, store); + expect(screen.getByTestId('sensitive-overlay')).not.toHaveTextContent('Sensitive content'); + expect(screen.getByTestId('sensitive-overlay')).toHaveTextContent('Hide'); + }); + + it('can be toggled', () => { + render(, 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'); + }); + }); +}); diff --git a/app/soapbox/components/statuses/moderation-overlay.tsx b/app/soapbox/components/statuses/moderation-overlay.tsx deleted file mode 100644 index 6d572eb3ab..0000000000 --- a/app/soapbox/components/statuses/moderation-overlay.tsx +++ /dev/null @@ -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(false); - - const toggleVisibility = (event: React.MouseEvent) => { - event.stopPropagation(); - - setVisible((prevValue) => !prevValue); - }; - - return ( -
- {visible ? ( - - - )} - - - -
- )} -
- ); -}; - -export default ModerationOverlay; \ No newline at end of file diff --git a/app/soapbox/components/statuses/sensitive-content-overlay.tsx b/app/soapbox/components/statuses/sensitive-content-overlay.tsx new file mode 100644 index 0000000000..7aebfe521c --- /dev/null +++ b/app/soapbox/components/statuses/sensitive-content-overlay.tsx @@ -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(defaultMediaVisibility(status, displayMedia)); + + const toggleVisibility = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (onToggleVisibility) { + onToggleVisibility(); + } else { + setVisible((prevValue) => !prevValue); + } + }; + + useEffect(() => { + if (typeof props.visible !== 'undefined') { + setVisible(!!props.visible); + } + }, [props.visible]); + + return ( +
+ {visible ? ( + + + )} + + ) : null} + + + +
+ )} +
+ ); +}; + +export default SensitiveContentOverlay; \ No newline at end of file diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 4f3d126593..d743fd08d9 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -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 { /** Horizontal alignment of children. */ alignItems?: 'center' | 'start', /** Vertical alignment of children. */ - justifyContent?: 'center' + justifyContent?: keyof typeof justifyContentOptions /** Extra class names on the
element. */ className?: string /** Whether to let the flexbox grow. */ diff --git a/app/soapbox/features/ads/components/ad.tsx b/app/soapbox/features/ads/components/ad.tsx index c19eec6efb..1112c788ce 100644 --- a/app/soapbox/features/ads/components/ad.tsx +++ b/app/soapbox/features/ads/components/ad.tsx @@ -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 = ({ card, impression, expires }) => { +const Ad: React.FC = ({ ad }) => { const queryClient = useQueryClient(); const instance = useAppSelector(state => state.instance); @@ -28,6 +23,14 @@ const Ad: React.FC = ({ card, impression, expires }) => { const infobox = useRef(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 = ({ 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 = ({ card, impression, expires }) => { clearTimeout(timer.current); } }; - }, [expires]); + }, [ad.expires_at]); return (
@@ -113,7 +108,7 @@ const Ad: React.FC = ({ card, impression, expires }) => { - { }} horizontal /> + { }} horizontal /> @@ -126,11 +121,15 @@ const Ad: React.FC = ({ card, impression, expires }) => { - + {ad.reason ? ( + ad.reason + ) : ( + + )} diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index b9c504bff6..bd17fa91c3 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -7,6 +7,7 @@ import type { Card } from 'soapbox/types/entities'; const PROVIDERS: Record Promise> = { 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. */ diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts index ace4021f06..bc86e86867 100644 --- a/app/soapbox/features/ads/providers/rumble.ts +++ b/app/soapbox/features/ads/providers/rumble.ts @@ -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), })); } } diff --git a/app/soapbox/features/ads/providers/truth.ts b/app/soapbox/features/ads/providers/truth.ts new file mode 100644 index 0000000000..92f2e99f88 --- /dev/null +++ b/app/soapbox/features/ads/providers/truth.ts @@ -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; diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index a7db21f2c7..52bc578af0 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -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 = ({ 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 = ({ - + + {(isUnderReview || isSensitive) ? ( + + ) : null} - + - {quote} + + + {quote} + diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 2ba338bcc4..dbcc373eea 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -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 = (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(); @@ -156,7 +154,7 @@ const Thread: React.FC = (props) => { }; }); - const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const [showMedia, setShowMedia] = useState(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia)); const [isLoaded, setIsLoaded] = useState(!!status); const [next, setNext] = useState(); @@ -165,7 +163,7 @@ const Thread: React.FC = (props) => { const scroller = useRef(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 = (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 = (props) => {
- {inReview ? ( - - ) : null} ) => { - 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) => { diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts index 115ad529c3..85dbcc8c61 100644 --- a/app/soapbox/normalizers/soapbox/ad.ts +++ b/app/soapbox/normalizers/soapbox/ad.ts @@ -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({ 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) => { const map = ImmutableMap(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, + })); }; diff --git a/app/soapbox/queries/ads.ts b/app/soapbox/queries/ads.ts index e950005b39..722f6a38a8 100644 --- a/app/soapbox/queries/ads.ts +++ b/app/soapbox/queries/ads.ts @@ -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, diff --git a/app/soapbox/utils/__tests__/ads.test.ts b/app/soapbox/utils/__tests__/ads.test.ts index 9890481109..f96f29936c 100644 --- a/app/soapbox/utils/__tests__/ads.test.ts +++ b/app/soapbox/utils/__tests__/ads.test.ts @@ -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); }); diff --git a/app/soapbox/utils/ads.ts b/app/soapbox/utils/ads.ts index 2d5a010403..9493151910 100644 --- a/app/soapbox/utils/ads.ts +++ b/app/soapbox/utils/ads.ts @@ -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; } diff --git a/app/soapbox/utils/status.ts b/app/soapbox/utils/status.ts index f40f2b68c0..98560be9c6 100644 --- a/app/soapbox/utils/status.ts +++ b/app/soapbox/utils/status.ts @@ -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'); };