Change how content warnings and filters are displayed in web UI (#31365)
This commit is contained in:
parent
98237207e6
commit
500f4925a5
11 changed files with 281 additions and 141 deletions
15
app/javascript/mastodon/components/content_warning.tsx
Normal file
15
app/javascript/mastodon/components/content_warning.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { StatusBanner, BannerVariant } from './status_banner';
|
||||
|
||||
export const ContentWarning: React.FC<{
|
||||
text: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ text, expanded, onClick }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Yellow}
|
||||
>
|
||||
<p dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</StatusBanner>
|
||||
);
|
23
app/javascript/mastodon/components/filter_warning.tsx
Normal file
23
app/javascript/mastodon/components/filter_warning.tsx
Normal file
|
@ -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 }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Blue}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='filter_warning.matches_filter'
|
||||
defaultMessage='Matches filter “{title}”'
|
||||
values={{ title }}
|
||||
/>
|
||||
</p>
|
||||
</StatusBanner>
|
||||
);
|
|
@ -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 (
|
||||
<HotKeys handlers={minHandlers} tabIndex={unfocusable ? null : -1}>
|
||||
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={unfocusable ? null : 0} ref={this.handleRef}>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
|
||||
{' '}
|
||||
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
|
||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
||||
</button>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
if (featured) {
|
||||
prepend = (
|
||||
<div className='status__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 (
|
||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||
|
@ -574,22 +569,27 @@ class Status extends ImmutablePureComponent {
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<StatusContent
|
||||
status={status}
|
||||
onClick={this.handleClick}
|
||||
expanded={expanded}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
onTranslate={this.handleTranslate}
|
||||
collapsible
|
||||
onCollapsedToggle={this.handleCollapsedToggle}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}
|
||||
|
||||
{media}
|
||||
{(status.get('spoiler_text').length > 0 && (!matchedFilters || this.state.showDespiteFilter)) && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />}
|
||||
|
||||
{expanded && hashtagBar}
|
||||
{expanded && (
|
||||
<>
|
||||
<StatusContent
|
||||
status={status}
|
||||
onClick={this.handleClick}
|
||||
onTranslate={this.handleTranslate}
|
||||
collapsible
|
||||
onCollapsedToggle={this.handleCollapsedToggle}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
|
||||
{media}
|
||||
{hashtagBar}
|
||||
</>
|
||||
)}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
|
@ -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 && (
|
||||
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
|
||||
);
|
||||
|
||||
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||
|
||||
return (
|
||||
|
@ -390,8 +380,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
|
||||
{filterButton}
|
||||
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
|
|
37
app/javascript/mastodon/components/status_banner.tsx
Normal file
37
app/javascript/mastodon/components/status_banner.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export enum BannerVariant {
|
||||
Yellow = 'yellow',
|
||||
Blue = 'blue',
|
||||
}
|
||||
|
||||
export const StatusBanner: React.FC<{
|
||||
children: React.ReactNode;
|
||||
variant: BannerVariant;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ children, variant, expanded, onClick }) => (
|
||||
<div
|
||||
className={
|
||||
variant === BannerVariant.Yellow
|
||||
? 'content-warning'
|
||||
: 'content-warning content-warning--filter'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
<button className='link-button' onClick={onClick}>
|
||||
{expanded ? (
|
||||
<FormattedMessage
|
||||
id='content_warning.hide'
|
||||
defaultMessage='Hide post'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='content_warning.show'
|
||||
defaultMessage='Show anyway'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
|
@ -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 {
|
|||
<PollContainer pollId={status.get('poll')} lang={language} />
|
||||
);
|
||||
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
let mentionsPlaceholder = '';
|
||||
|
||||
const mentionLinks = status.get('mentions').map(item => (
|
||||
<Link to={`/@${item.get('acct')}`} key={item.get('id')} className='status-link mention'>
|
||||
@<span>{item.get('username')}</span>
|
||||
</Link>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||
|
||||
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
|
||||
|
||||
if (hidden) {
|
||||
mentionsPlaceholder = <div>{mentionLinks}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} />
|
||||
{' '}
|
||||
<button type='button' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick} aria-expanded={!hidden}>{toggleText}</button>
|
||||
</p>
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{!hidden && poll}
|
||||
{translateButton}
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.onClick) {
|
||||
if (this.props.onClick) {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue