diff --git a/app/javascript/images/filter-stripes.svg b/app/javascript/images/filter-stripes.svg new file mode 100755 index 0000000000..4c1b58cb74 --- /dev/null +++ b/app/javascript/images/filter-stripes.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/mastodon/components/content_warning.tsx b/app/javascript/mastodon/components/content_warning.tsx new file mode 100644 index 0000000000..df8afca74d --- /dev/null +++ b/app/javascript/mastodon/components/content_warning.tsx @@ -0,0 +1,15 @@ +import { StatusBanner, BannerVariant } from './status_banner'; + +export const ContentWarning: React.FC<{ + text: string; + expanded?: boolean; + onClick?: () => void; +}> = ({ text, expanded, onClick }) => ( + +

+ +); diff --git a/app/javascript/mastodon/components/filter_warning.tsx b/app/javascript/mastodon/components/filter_warning.tsx new file mode 100644 index 0000000000..4305e43038 --- /dev/null +++ b/app/javascript/mastodon/components/filter_warning.tsx @@ -0,0 +1,23 @@ +import { FormattedMessage } from 'react-intl'; + +import { StatusBanner, BannerVariant } from './status_banner'; + +export const FilterWarning: React.FC<{ + title: string; + expanded?: boolean; + onClick?: () => void; +}> = ({ title, expanded, onClick }) => ( + +

+ +

+
+); diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 6e3792d7dc..7236c9633d 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -13,6 +13,8 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; +import { ContentWarning } from 'mastodon/components/content_warning'; +import { FilterWarning } from 'mastodon/components/filter_warning'; import { Icon } from 'mastodon/components/icon'; import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router'; @@ -140,7 +142,7 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status) && !(this.context?.hideMediaByDefault), - forceFilter: undefined, + showDespiteFilter: undefined, }; componentDidUpdate (prevProps) { @@ -152,7 +154,7 @@ class Status extends ImmutablePureComponent { if (this.props.status?.get('id') !== prevProps.status?.get('id')) { this.setState({ showMedia: defaultMediaVisibility(this.props.status) && !(this.context?.hideMediaByDefault), - forceFilter: undefined, + showDespiteFilter: undefined, }); } } @@ -325,20 +327,32 @@ class Status extends ImmutablePureComponent { }; handleHotkeyToggleHidden = () => { - this.props.onToggleHidden(this._properStatus()); + const { onToggleHidden } = this.props; + const status = this._properStatus(); + + if (status.get('matched_filters')) { + const expandedBecauseOfCW = !status.get('hidden') || status.get('spoiler_text').length === 0; + const expandedBecauseOfFilter = this.state.showDespiteFilter; + + if (expandedBecauseOfFilter && !expandedBecauseOfCW) { + onToggleHidden(status); + } else if (expandedBecauseOfFilter && expandedBecauseOfCW) { + onToggleHidden(status); + this.handleFilterToggle(); + } else { + this.handleFilterToggle(); + } + } else { + onToggleHidden(status); + } }; handleHotkeyToggleSensitive = () => { this.handleToggleMediaVisibility(); }; - handleUnfilterClick = e => { - this.setState({ forceFilter: false }); - e.preventDefault(); - }; - - handleFilterClick = () => { - this.setState({ forceFilter: true }); + handleFilterToggle = () => { + this.setState(state => ({ ...state, showDespiteFilter: !state.showDespiteFilter })); }; _properStatus () { @@ -396,25 +410,6 @@ class Status extends ImmutablePureComponent { const connectReply = nextInReplyToId && nextInReplyToId === status.get('id'); const matchedFilters = status.get('matched_filters'); - if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { - const minHandlers = this.props.muted ? {} : { - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - }; - - return ( - -
- : {matchedFilters.join(', ')}. - {' '} - -
-
- ); - } - if (featured) { prepend = (
@@ -548,7 +543,7 @@ class Status extends ImmutablePureComponent { } const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); - const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; + const expanded = (!matchedFilters || this.state.showDespiteFilter) && (!status.get('hidden') || status.get('spoiler_text').length === 0); return ( @@ -574,22 +569,27 @@ class Status extends ImmutablePureComponent {
- + {matchedFilters && } - {media} + {(status.get('spoiler_text').length > 0 && (!matchedFilters || this.state.showDespiteFilter)) && } - {expanded && hashtagBar} + {expanded && ( + <> + - + {media} + {hashtagBar} + + )} + + diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 2a7bd0a305..f24f81e1b2 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -17,7 +17,6 @@ import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.svg?react'; -import VisibilityIcon from '@/material-icons/400-24px/visibility.svg?react'; import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react'; import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react'; @@ -61,7 +60,6 @@ const messages = defineMessages({ admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, - hide: { id: 'status.hide', defaultMessage: 'Hide post' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, @@ -241,10 +239,6 @@ class StatusActionBar extends ImmutablePureComponent { navigator.clipboard.writeText(url); }; - handleHideClick = () => { - this.props.onFilter(); - }; - render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.props.identity; @@ -377,10 +371,6 @@ class StatusActionBar extends ImmutablePureComponent { reblogIconComponent = RepeatDisabledIcon; } - const filterButton = this.props.onFilter && ( - - ); - const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); return ( @@ -390,8 +380,6 @@ class StatusActionBar extends ImmutablePureComponent { - {filterButton} - void; +}> = ({ children, variant, expanded, onClick }) => ( +
+ {children} + + +
+); diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 96452374dc..3316be8350 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -4,7 +4,7 @@ import { PureComponent } from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import classnames from 'classnames'; -import { Link, withRouter } from 'react-router-dom'; +import { withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; @@ -15,7 +15,6 @@ import PollContainer from 'mastodon/containers/poll_container'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; - const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) /** @@ -73,8 +72,6 @@ class StatusContent extends PureComponent { identity: identityContextPropShape, status: ImmutablePropTypes.map.isRequired, statusContent: PropTypes.string, - expanded: PropTypes.bool, - onExpandedToggle: PropTypes.func, onTranslate: PropTypes.func, onClick: PropTypes.func, collapsible: PropTypes.bool, @@ -87,10 +84,6 @@ class StatusContent extends PureComponent { history: PropTypes.object.isRequired }; - state = { - hidden: true, - }; - _updateStatusLinks () { const node = this.node; @@ -218,17 +211,6 @@ class StatusContent extends PureComponent { this.startXY = null; }; - handleSpoilerClick = (e) => { - e.preventDefault(); - - if (this.props.onExpandedToggle) { - // The parent manages the state - this.props.onExpandedToggle(); - } else { - this.setState({ hidden: !this.state.hidden }); - } - }; - handleTranslate = () => { this.props.onTranslate(); }; @@ -240,18 +222,15 @@ class StatusContent extends PureComponent { render () { const { status, intl, statusContent } = this.props; - const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const renderReadMore = this.props.onClick && status.get('collapsed'); const contentLocale = intl.locale.replace(/[_-].*/, ''); const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); const content = { __html: statusContent ?? getStatusContent(status) }; - const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') }; const language = status.getIn(['translation', 'language']) || status.get('language'); const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.props.history, - 'status__content--with-spoiler': status.get('spoiler_text').length > 0, 'status__content--collapsed': renderReadMore, }); @@ -269,38 +248,7 @@ class StatusContent extends PureComponent { ); - if (status.get('spoiler_text').length > 0) { - let mentionsPlaceholder = ''; - - const mentionLinks = status.get('mentions').map(item => ( - - @{item.get('username')} - - )).reduce((aggregate, item) => [...aggregate, item, ' '], []); - - const toggleText = hidden ? : ; - - if (hidden) { - mentionsPlaceholder =
{mentionLinks}
; - } - - return ( -
- - - {mentionsPlaceholder} - -
- - {!hidden && poll} - {translateButton} -
- ); - } else if (this.props.onClick) { + if (this.props.onClick) { return ( <>
diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx index baec016117..65ea9b5d5e 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx @@ -8,11 +8,13 @@ import type { List as ImmutableList, RecordOf } from 'immutable'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; +import { toggleStatusSpoilers } from 'mastodon/actions/statuses'; import { Avatar } from 'mastodon/components/avatar'; +import { ContentWarning } from 'mastodon/components/content_warning'; import { DisplayName } from 'mastodon/components/display_name'; import { Icon } from 'mastodon/components/icon'; import type { Status } from 'mastodon/models/status'; -import { useAppSelector } from 'mastodon/store'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { EmbeddedStatusContent } from './embedded_status_content'; @@ -23,6 +25,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ }) => { const history = useHistory(); const clickCoordinatesRef = useRef<[number, number] | null>(); + const dispatch = useAppDispatch(); const status = useAppSelector( (state) => state.statuses.get(statusId) as Status | undefined, @@ -96,15 +99,21 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ [], ); + const handleContentWarningClick = useCallback(() => { + dispatch(toggleStatusSpoilers(statusId)); + }, [dispatch, statusId]); + if (!status) { return null; } // Assign status attributes to variables with a forced type, as status is not yet properly typed const contentHtml = status.get('contentHtml') as string; + const contentWarning = status.get('spoilerHtml') as string; const poll = status.get('poll'); const language = status.get('language') as string; const mentions = status.get('mentions') as ImmutableList; + const expanded = !status.get('hidden') || !contentWarning; const mediaAttachmentsSize = ( status.get('media_attachments') as ImmutableList ).size; @@ -124,14 +133,24 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
- + {contentWarning && ( + + )} - {(poll || mediaAttachmentsSize > 0) && ( + {(!contentWarning || expanded) && ( + + )} + + {expanded && (poll || mediaAttachmentsSize > 0) && (
{!!poll && ( <> diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index bc81fd2dfb..8ee1ec9b9b 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import { AnimatedNumber } from 'mastodon/components/animated_number'; +import { ContentWarning } from 'mastodon/components/content_warning'; import EditedTimestamp from 'mastodon/components/edited_timestamp'; import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar'; import { Icon } from 'mastodon/components/icon'; @@ -277,17 +278,20 @@ class DetailedStatus extends ImmutablePureComponent { - + {status.get('spoiler_text').length > 0 && } - {media} + {expanded && ( + <> + - {expanded && hashtagBar} + {media} + {hashtagBar} + + )}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ba351032ff..88920431fb 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -192,6 +192,8 @@ "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", "confirmations.unfollow.title": "Unfollow user?", + "content_warning.hide": "Hide post", + "content_warning.show": "Show anyway", "conversation.delete": "Delete conversation", "conversation.mark_as_read": "Mark as read", "conversation.open": "View conversation", @@ -299,6 +301,7 @@ "filter_modal.select_filter.subtitle": "Use an existing category or create a new one", "filter_modal.select_filter.title": "Filter this post", "filter_modal.title.status": "Filter a post", + "filter_warning.matches_filter": "Matches filter “{title}”", "filtered_notifications_banner.pending_requests": "From {count, plural, =0 {no one} one {one person} other {# people}} you may know", "filtered_notifications_banner.title": "Filtered notifications", "firehose.all": "All", @@ -785,8 +788,6 @@ "status.favourite": "Favorite", "status.favourites": "{count, plural, one {favorite} other {favorites}}", "status.filter": "Filter this post", - "status.filtered": "Filtered", - "status.hide": "Hide post", "status.history.created": "{name} created {date}", "status.history.edited": "{name} edited {date}", "status.load_more": "Load more", @@ -814,10 +815,7 @@ "status.report": "Report @{name}", "status.sensitive_warning": "Sensitive content", "status.share": "Share", - "status.show_filter_reason": "Show anyway", - "status.show_less": "Show less", "status.show_less_all": "Show less for all", - "status.show_more": "Show more", "status.show_more_all": "Show more for all", "status.show_original": "Show original", "status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 3d2c466254..6157a83af4 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -620,7 +620,7 @@ body > [data-popper-placement] { .spoiler-input__input { padding: 12px 12px - 5px; - background: mix($ui-base-color, $ui-highlight-color, 85%); + background: rgba($ui-highlight-color, 0.05); color: $highlight-text-color; } @@ -1383,6 +1383,14 @@ body > [data-popper-placement] { } } + .content-warning { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + .media-gallery, .video-player, .audio-player, @@ -1441,7 +1449,9 @@ body > [data-popper-placement] { .picture-in-picture-placeholder, .more-from-author, .status-card, - .hashtag-bar { + .hashtag-bar, + .content-warning, + .filter-warning { margin-inline-start: $thread-margin; width: calc(100% - $thread-margin); } @@ -1690,6 +1700,14 @@ body > [data-popper-placement] { padding: 0; margin-bottom: 16px; } + + .content-warning { + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + } } .scrollable > div:first-child .detailed-status { @@ -10518,39 +10536,53 @@ noscript { } &__embedded-status { + display: flex; + flex-direction: column; + gap: 8px; cursor: pointer; &__account { display: flex; align-items: center; gap: 4px; - margin-bottom: 8px; color: $dark-text-color; + font-size: 15px; + line-height: 22px; bdi { - color: inherit; + color: $darker-text-color; } } - .account__avatar { - opacity: 0.5; - } - &__content { display: -webkit-box; font-size: 15px; line-height: 22px; - color: $dark-text-color; + color: $darker-text-color; -webkit-line-clamp: 4; -webkit-box-orient: vertical; max-height: 4 * 22px; overflow: hidden; + p { + display: none; + + &:first-child { + display: initial; + } + } + p, a { color: inherit; } } + + .reply-indicator__attachments { + font-size: 15px; + line-height: 22px; + color: $dark-text-color; + } } } @@ -10625,7 +10657,9 @@ noscript { .picture-in-picture-placeholder, .more-from-author, .status-card, - .hashtag-bar { + .hashtag-bar, + .content-warning, + .filter-warning { margin-inline-start: $icon-margin; width: calc(100% - $icon-margin); } @@ -10833,3 +10867,53 @@ noscript { } } } + +.content-warning { + background: rgba($ui-highlight-color, 0.05); + color: $secondary-text-color; + border-top: 1px solid; + border-bottom: 1px solid; + border-color: rgba($ui-highlight-color, 0.15); + padding: 8px (5px + 8px); + position: relative; + font-size: 15px; + line-height: 22px; + + p { + margin-bottom: 8px; + } + + .link-button { + font-size: inherit; + line-height: inherit; + font-weight: 500; + } + + &::before, + &::after { + content: ''; + display: block; + position: absolute; + height: 100%; + background: url('../images/warning-stripes.svg') repeat-y; + width: 5px; + top: 0; + } + + &::before { + border-start-start-radius: 4px; + border-end-start-radius: 4px; + inset-inline-start: 0; + } + + &::after { + border-start-end-radius: 4px; + border-end-end-radius: 4px; + inset-inline-end: 0; + } + + &--filter::before, + &--filter::after { + background-image: url('../images/filter-stripes.svg'); + } +}