diff options
author | Claire <claire.github-309c@sitedethib.com> | 2022-05-11 09:37:48 +0200 |
---|---|---|
committer | Claire <claire.github-309c@sitedethib.com> | 2022-05-11 09:37:48 +0200 |
commit | 5fd8780b1429acd2bea908695e13c41375d189d7 (patch) | |
tree | c870d368c2a9d0bfd2176057c0b99dad48338248 /app | |
parent | e8b8ac8908c6623f0fd7ffccc7de3882a773b72f (diff) | |
parent | 95555f15b55291b97477465f8d8a7eba526d6522 (diff) |
Merge branch 'main' into glitch-soc/merge-upstream
Conflicts: - `package.json`: Not really a conflict, upstream updated a dependency textually adjacent to a glitch-soc-only one. Updated the dependency as upstream did.
Diffstat (limited to 'app')
32 files changed, 479 insertions, 310 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 03c07c50b..9949206cb 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -45,7 +45,6 @@ class AccountsController < ApplicationController limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE @statuses = filtered_statuses.without_reblogs.limit(limit) @statuses = cache_collection(@statuses, Status) - render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end format.json do diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 64736e77f..46821a200 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -27,7 +27,6 @@ class TagsController < ApplicationController format.rss do expires_in 0, public: true - render xml: RSS::TagSerializer.render(@tag, @statuses) end format.json do diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1c670fde0..b26e68c4d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -244,7 +244,7 @@ module ApplicationHelper end.values end - def prerender_custom_emojis(html, custom_emojis) - EmojiFormatter.new(html, custom_emojis, animate: prefers_autoplay?).to_s + def prerender_custom_emojis(html, custom_emojis, other_options = {}) + EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s end end diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 53e100dd2..526efd766 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -18,6 +18,32 @@ module FormattingHelper html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type) end + def rss_status_content_format(status) + html = status_content_format(status) + + before_html = begin + if status.spoiler_text? + "<p><strong>#{I18n.t('rss.content_warning', locale: valid_locale_or_nil(status.language))}</strong> #{h(status.spoiler_text)}</p><hr />" + else + '' + end + end.html_safe # rubocop:disable Rails/OutputSafety + + after_html = begin + if status.preloadable_poll + "<p>#{status.preloadable_poll.options.map { |o| "<input type=#{status.preloadable_poll.multiple? ? 'checkbox' : 'radio'} disabled /> #{h(o)}" }.join('<br />')}</p>" + else + '' + end + end.html_safe # rubocop:disable Rails/OutputSafety + + prerender_custom_emojis( + safe_join([before_html, html, after_html]), + status.emojis, + style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' + ).to_str + end + def account_bio_format(account) html_aware_format(account.note, account.local?) end diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index ce7bb6d5f..eedf61dc9 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -77,6 +77,8 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; +export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL'; + export function fetchAccount(id) { return (dispatch, getState) => { dispatch(fetchRelationships([id])); @@ -780,3 +782,8 @@ export function unpinAccountFail(error) { error, }; }; + +export const revealAccount = id => ({ + type: ACCOUNT_REVEAL, + id, +}); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 62b5843a9..af9f119c8 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -18,6 +18,8 @@ const messages = defineMessages({ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, }); export default @injectIntl @@ -33,6 +35,7 @@ class Account extends ImmutablePureComponent { hidden: PropTypes.bool, actionIcon: PropTypes.string, actionTitle: PropTypes.string, + defaultAction: PropTypes.string, onActionClick: PropTypes.func, }; @@ -61,7 +64,7 @@ class Account extends ImmutablePureComponent { } render () { - const { account, intl, hidden, onActionClick, actionIcon, actionTitle } = this.props; + const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction } = this.props; if (!account) { return <div />; @@ -105,6 +108,10 @@ class Account extends ImmutablePureComponent { {hidingNotificationsButton} </Fragment> ); + } else if (defaultAction === 'mute') { + buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />; + } else if (defaultAction === 'block') { + buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />; } else if (!account.get('moved') || following) { buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; } diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js index 570505833..12ab7d2df 100644 --- a/app/javascript/mastodon/components/avatar.js +++ b/app/javascript/mastodon/components/avatar.js @@ -2,11 +2,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { autoPlayGif } from '../initial_state'; +import classNames from 'classnames'; export default class Avatar extends React.PureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map, size: PropTypes.number.isRequired, style: PropTypes.object, inline: PropTypes.bool, @@ -37,15 +38,6 @@ export default class Avatar extends React.PureComponent { const { account, size, animate, inline } = this.props; const { hovering } = this.state; - const src = account.get('avatar'); - const staticSrc = account.get('avatar_static'); - - let className = 'account__avatar'; - - if (inline) { - className = className + ' account__avatar-inline'; - } - const style = { ...this.props.style, width: `${size}px`, @@ -53,15 +45,21 @@ export default class Avatar extends React.PureComponent { backgroundSize: `${size}px ${size}px`, }; - if (hovering || animate) { - style.backgroundImage = `url(${src})`; - } else { - style.backgroundImage = `url(${staticSrc})`; + if (account) { + const src = account.get('avatar'); + const staticSrc = account.get('avatar_static'); + + if (hovering || animate) { + style.backgroundImage = `url(${src})`; + } else { + style.backgroundImage = `url(${staticSrc})`; + } } + return ( <div - className={className} + className={classNames('account__avatar', { 'account__avatar-inline': inline })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={style} diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 2830bee29..8e6b9f063 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -82,6 +82,7 @@ class Header extends ImmutablePureComponent { onEditAccountNote: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, domain: PropTypes.string.isRequired, + hidden: PropTypes.bool, }; openEditProfile = () => { @@ -123,7 +124,7 @@ class Header extends ImmutablePureComponent { } render () { - const { account, intl, domain } = this.props; + const { account, hidden, intl, domain } = this.props; if (!account) { return null; @@ -267,21 +268,25 @@ class Header extends ImmutablePureComponent { {!suspended && info} </div> - <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' /> + {!(suspended || hidden) && <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />} </div> <div className='account__header__bar'> <div className='account__header__tabs'> <a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'> - <Avatar account={account} size={90} /> + <Avatar account={suspended || hidden ? undefined : account} size={90} /> </a> <div className='spacer' /> {!suspended && ( <div className='account__header__tabs__buttons'> - {actionBtn} - {bellBtn} + {!hidden && ( + <React.Fragment> + {actionBtn} + {bellBtn} + </React.Fragment> + )} <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> </div> @@ -295,30 +300,30 @@ class Header extends ImmutablePureComponent { </h1> </div> - <div className='account__header__extra'> - <div className='account__header__bio'> - {fields.size > 0 && ( - <div className='account__header__fields'> - {fields.map((pair, i) => ( - <dl key={i}> - <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' /> + {!(suspended || hidden) && ( + <div className='account__header__extra'> + <div className='account__header__bio'> + {fields.size > 0 && ( + <div className='account__header__fields'> + {fields.map((pair, i) => ( + <dl key={i}> + <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' /> - <dd className={`${pair.get('verified_at') ? 'verified' : ''} translate`} title={pair.get('value_plain')}> - {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} /> - </dd> - </dl> - ))} - </div> - )} + <dd className={`${pair.get('verified_at') ? 'verified' : ''} translate`} title={pair.get('value_plain')}> + {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} /> + </dd> + </dl> + ))} + </div> + )} - {account.get('id') !== me && !suspended && <AccountNoteContainer account={account} />} + {account.get('id') !== me && <AccountNoteContainer account={account} />} - {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />} + {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />} - <div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div> - </div> + <div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div> + </div> - {!suspended && ( <div className='account__header__extra__links'> <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}> <ShortNumber @@ -341,8 +346,8 @@ class Header extends ImmutablePureComponent { /> </NavLink> </div> - )} - </div> + </div> + )} </div> </div> ); diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 507b6c895..fab0bc597 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent { onAddToList: PropTypes.func.isRequired, hideTabs: PropTypes.bool, domain: PropTypes.string.isRequired, + hidden: PropTypes.bool, }; static contextTypes = { @@ -91,7 +92,7 @@ export default class Header extends ImmutablePureComponent { } render () { - const { account, hideTabs } = this.props; + const { account, hidden, hideTabs } = this.props; if (account === null) { return null; @@ -99,7 +100,7 @@ export default class Header extends ImmutablePureComponent { return ( <div className='account-timeline__header'> - {account.get('moved') && <MovedNote from={account} to={account.get('moved')} />} + {(!hidden && account.get('moved')) && <MovedNote from={account} to={account.get('moved')} />} <InnerHeader account={account} @@ -117,9 +118,10 @@ export default class Header extends ImmutablePureComponent { onAddToList={this.handleAddToList} onEditAccountNote={this.handleEditAccountNote} domain={this.props.domain} + hidden={hidden} /> - {!hideTabs && ( + {!(hideTabs || hidden) && ( <div className='account__section-headline'> <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink> <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' /></NavLink> diff --git a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js new file mode 100644 index 000000000..6b025596c --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { revealAccount } from 'mastodon/actions/accounts'; +import { FormattedMessage } from 'react-intl'; +import Button from 'mastodon/components/button'; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + + reveal () { + dispatch(revealAccount(accountId)); + }, + +}); + +export default @connect(() => {}, mapDispatchToProps) +class LimitedAccountHint extends React.PureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + reveal: PropTypes.func, + } + + render () { + const { reveal } = this.props; + + return ( + <div className='limited-account-hint'> + <p><FormattedMessage id='limited_account_hint.title' defaultMessage='This profile has been hidden by the moderators of your server.' /></p> + <Button onClick={reveal}><FormattedMessage id='limited_account_hint.action' defaultMessage='Show profile anyway' /></Button> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index b3f8521cb..371794dd7 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { makeGetAccount } from '../../../selectors'; +import { makeGetAccount, getAccountHidden } from '../../../selectors'; import Header from '../components/header'; import { followAccount, @@ -33,6 +33,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, { accountId }) => ({ account: getAccount(state, accountId), domain: state.getIn(['meta', 'domain']), + hidden: getAccountHidden(state, accountId), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 5122aec4e..5b592c5a7 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -16,6 +16,8 @@ import MissingIndicator from 'mastodon/components/missing_indicator'; import TimelineHint from 'mastodon/components/timeline_hint'; import { me } from 'mastodon/initial_state'; import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines'; +import LimitedAccountHint from './components/limited_account_hint'; +import { getAccountHidden } from 'mastodon/selectors'; const emptyList = ImmutableList(); @@ -40,6 +42,7 @@ const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) = isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), suspended: state.getIn(['accounts', accountId, 'suspended'], false), + hidden: getAccountHidden(state, accountId), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), }; }; @@ -70,6 +73,7 @@ class AccountTimeline extends ImmutablePureComponent { blockedBy: PropTypes.bool, isAccount: PropTypes.bool, suspended: PropTypes.bool, + hidden: PropTypes.bool, remote: PropTypes.bool, remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, @@ -128,7 +132,7 @@ class AccountTimeline extends ImmutablePureComponent { } render () { - const { statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props; + const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -149,8 +153,12 @@ class AccountTimeline extends ImmutablePureComponent { let emptyMessage; + const forceEmptyState = suspended || blockedBy || hidden; + if (suspended) { emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (hidden) { + emptyMessage = <LimitedAccountHint accountId={accountId} />; } else if (blockedBy) { emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; } else if (remote && statusIds.isEmpty()) { @@ -166,14 +174,14 @@ class AccountTimeline extends ImmutablePureComponent { <ColumnBackButton multiColumn={multiColumn} /> <StatusList - prepend={<HeaderContainer accountId={this.props.accountId} />} + prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} />} alwaysPrepend append={remoteMessage} scrollKey='account_timeline' - statusIds={(suspended || blockedBy) ? emptyList : statusIds} + statusIds={forceEmptyState ? emptyList : statusIds} featuredStatusIds={featuredStatusIds} isLoading={isLoading} - hasMore={hasMore} + hasMore={!forceEmptyState && hasMore} onLoadMore={this.handleLoadMore} emptyMessage={emptyMessage} bindToDocument={!multiColumn} diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index 7ec177434..e00f2b60e 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -69,7 +69,7 @@ class Blocks extends ImmutablePureComponent { bindToDocument={!multiColumn} > {accountIds.map(id => - <AccountContainer key={id} id={id} />, + <AccountContainer key={id} id={id} defaultAction='block' />, )} </ScrollableList> </Column> diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index 224e74b3d..5b7f402f8 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -19,6 +19,8 @@ import ColumnBackButton from '../../components/column_back_button'; import ScrollableList from '../../components/scrollable_list'; import MissingIndicator from 'mastodon/components/missing_indicator'; import TimelineHint from 'mastodon/components/timeline_hint'; +import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; +import { getAccountHidden } from 'mastodon/selectors'; const mapStateToProps = (state, { params: { acct, id } }) => { const accountId = id || state.getIn(['accounts_map', acct]); @@ -37,6 +39,8 @@ const mapStateToProps = (state, { params: { acct, id } }) => { accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']), isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true), + suspended: state.getIn(['accounts', accountId, 'suspended'], false), + hidden: getAccountHidden(state, accountId), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), }; }; @@ -64,6 +68,8 @@ class Followers extends ImmutablePureComponent { isLoading: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + suspended: PropTypes.bool, + hidden: PropTypes.bool, remote: PropTypes.bool, remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, @@ -101,7 +107,7 @@ class Followers extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; + const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -121,7 +127,13 @@ class Followers extends ImmutablePureComponent { let emptyMessage; - if (blockedBy) { + const forceEmptyState = blockedBy || suspended || hidden; + + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (hidden) { + emptyMessage = <LimitedAccountHint accountId={accountId} />; + } else if (blockedBy) { emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; } else if (remote && accountIds.isEmpty()) { emptyMessage = <RemoteHint url={remoteUrl} />; @@ -137,7 +149,7 @@ class Followers extends ImmutablePureComponent { <ScrollableList scrollKey='followers' - hasMore={hasMore} + hasMore={!forceEmptyState && hasMore} isLoading={isLoading} onLoadMore={this.handleLoadMore} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} @@ -146,7 +158,7 @@ class Followers extends ImmutablePureComponent { emptyMessage={emptyMessage} bindToDocument={!multiColumn} > - {blockedBy ? [] : accountIds.map(id => + {forceEmptyState ? [] : accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />, )} </ScrollableList> diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index aadce1644..143082d76 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -19,6 +19,8 @@ import ColumnBackButton from '../../components/column_back_button'; import ScrollableList from '../../components/scrollable_list'; import MissingIndicator from 'mastodon/components/missing_indicator'; import TimelineHint from 'mastodon/components/timeline_hint'; +import LimitedAccountHint from '../account_timeline/components/limited_account_hint'; +import { getAccountHidden } from 'mastodon/selectors'; const mapStateToProps = (state, { params: { acct, id } }) => { const accountId = id || state.getIn(['accounts_map', acct]); @@ -37,6 +39,8 @@ const mapStateToProps = (state, { params: { acct, id } }) => { accountIds: state.getIn(['user_lists', 'following', accountId, 'items']), hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']), isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true), + suspended: state.getIn(['accounts', accountId, 'suspended'], false), + hidden: getAccountHidden(state, accountId), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), }; }; @@ -64,6 +68,8 @@ class Following extends ImmutablePureComponent { isLoading: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + suspended: PropTypes.bool, + hidden: PropTypes.bool, remote: PropTypes.bool, remoteUrl: PropTypes.string, multiColumn: PropTypes.bool, @@ -101,7 +107,7 @@ class Following extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; + const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props; if (!isAccount) { return ( @@ -121,7 +127,13 @@ class Following extends ImmutablePureComponent { let emptyMessage; - if (blockedBy) { + const forceEmptyState = blockedBy || suspended || hidden; + + if (suspended) { + emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; + } else if (hidden) { + emptyMessage = <LimitedAccountHint accountId={accountId} />; + } else if (blockedBy) { emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; } else if (remote && accountIds.isEmpty()) { emptyMessage = <RemoteHint url={remoteUrl} />; @@ -137,7 +149,7 @@ class Following extends ImmutablePureComponent { <ScrollableList scrollKey='following' - hasMore={hasMore} + hasMore={!forceEmptyState && hasMore} isLoading={isLoading} onLoadMore={this.handleLoadMore} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} @@ -146,7 +158,7 @@ class Following extends ImmutablePureComponent { emptyMessage={emptyMessage} bindToDocument={!multiColumn} > - {blockedBy ? [] : accountIds.map(id => + {forceEmptyState ? [] : accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />, )} </ScrollableList> diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js index c1d50d194..c21433cc4 100644 --- a/app/javascript/mastodon/features/mutes/index.js +++ b/app/javascript/mastodon/features/mutes/index.js @@ -69,7 +69,7 @@ class Mutes extends ImmutablePureComponent { bindToDocument={!multiColumn} > {accountIds.map(id => - <AccountContainer key={id} id={id} />, + <AccountContainer key={id} id={id} defaultAction='mute' />, )} </ScrollableList> </Column> diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js index 530ed8e60..b5589668c 100644 --- a/app/javascript/mastodon/reducers/accounts.js +++ b/app/javascript/mastodon/reducers/accounts.js @@ -1,4 +1,5 @@ -import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; +import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'mastodon/actions/importer'; +import { ACCOUNT_REVEAL } from 'mastodon/actions/accounts'; import { Map as ImmutableMap, fromJS } from 'immutable'; const initialState = ImmutableMap(); @@ -10,6 +11,8 @@ const normalizeAccount = (state, account) => { delete account.following_count; delete account.statuses_count; + account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited; + return state.set(account.id, fromJS(account)); }; @@ -27,6 +30,8 @@ export default function accounts(state = initialState, action) { return normalizeAccount(state, action.account); case ACCOUNTS_IMPORT: return normalizeAccounts(state, action.accounts); + case ACCOUNT_REVEAL: + return state.setIn([action.id, 'hidden'], false); default: return state; } diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 1e19db65d..3121774b3 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -175,3 +175,11 @@ export const getAccountGallery = createSelector([ return medias; }); + +export const getAccountHidden = createSelector([ + (state, id) => state.getIn(['accounts', id, 'hidden']), + (state, id) => state.getIn(['relationships', id, 'following']) || state.getIn(['relationships', id, 'requested']), + (state, id) => id === me, +], (hidden, followingOrRequested, isSelf) => { + return hidden && !(isSelf || followingOrRequested); +}); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 4a805992e..e6133ee32 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -4037,6 +4037,15 @@ a.status-card.compact:hover { vertical-align: middle; } +.limited-account-hint { + p { + color: $secondary-text-color; + font-size: 15px; + font-weight: 500; + margin-bottom: 20px; + } +} + .empty-column-indicator, .error-column, .follow_requests-unlocked_explanation { diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb index f808f3a22..194849c23 100644 --- a/app/lib/emoji_formatter.rb +++ b/app/lib/emoji_formatter.rb @@ -11,6 +11,7 @@ class EmojiFormatter # @param [Array<CustomEmoji>] custom_emojis # @param [Hash] options # @option options [Boolean] :animate + # @option options [String] :style def initialize(html, custom_emojis, options = {}) raise ArgumentError unless html.html_safe? @@ -85,14 +86,29 @@ class EmojiFormatter def image_for_emoji(shortcode, emoji) original_url, static_url = emoji - if animate? - image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:") - else - image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url }) - end + image_tag( + animate? ? original_url : static_url, + image_attributes.merge(alt: ":#{shortcode}:", title: ":#{shortcode}:", data: image_data_attributes(original_url, static_url)) + ) + end + + def image_attributes + { rel: 'emoji', draggable: false, width: 16, height: 16, class: image_class_names, style: image_style } + end + + def image_data_attributes(original_url, static_url) + { original: original_url, static: static_url } unless animate? + end + + def image_class_names + animate? ? 'emojione' : 'emojione custom-emoji' + end + + def image_style + @options[:style] end def animate? - @options[:animate] + @options[:animate] || @options.key?(:style) end end diff --git a/app/lib/rss/builder.rb b/app/lib/rss/builder.rb new file mode 100644 index 000000000..a9b3f08c5 --- /dev/null +++ b/app/lib/rss/builder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class RSS::Builder + attr_reader :dsl + + def self.build + new.tap do |builder| + yield builder.dsl + end.to_xml + end + + def initialize + @dsl = RSS::Channel.new + end + + def to_xml + ('<?xml version="1.0" encoding="UTF-8"?>'.dup << Ox.dump(wrap_in_document, effort: :tolerant)).force_encoding('UTF-8') + end + + private + + def wrap_in_document + Ox::Document.new(version: '1.0').tap do |document| + document << Ox::Element.new('rss').tap do |rss| + rss['version'] = '2.0' + rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0' + rss['xmlns:media'] = 'http://search.yahoo.com/mrss/' + + rss << @dsl.to_element + end + end + end +end diff --git a/app/lib/rss/channel.rb b/app/lib/rss/channel.rb new file mode 100644 index 000000000..1dba94e47 --- /dev/null +++ b/app/lib/rss/channel.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class RSS::Channel < RSS::Element + def initialize + super() + + @root = create_element('channel') + end + + def title(str) + append_element('title', str) + end + + def link(str) + append_element('link', str) + end + + def last_build_date(date) + append_element('lastBuildDate', date.to_formatted_s(:rfc822)) + end + + def image(url, title, link) + append_element('image') do |image| + image << create_element('url', url) + image << create_element('title', title) + image << create_element('link', link) + end + end + + def description(str) + append_element('description', str) + end + + def generator(str) + append_element('generator', str) + end + + def icon(str) + append_element('webfeeds:icon', str) + end + + def logo(str) + append_element('webfeeds:logo', str) + end + + def item(&block) + @root << RSS::Item.with(&block) + end +end diff --git a/app/lib/rss/element.rb b/app/lib/rss/element.rb new file mode 100644 index 000000000..7142fa039 --- /dev/null +++ b/app/lib/rss/element.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class RSS::Element + def self.with(*args, &block) + new(*args).tap(&block).to_element + end + + def create_element(name, content = nil) + Ox::Element.new(name).tap do |element| + yield element if block_given? + element << content if content.present? + end + end + + def append_element(name, content = nil) + @root << create_element(name, content).tap do |element| + yield element if block_given? + end + end + + def to_element + @root + end +end diff --git a/app/lib/rss/item.rb b/app/lib/rss/item.rb new file mode 100644 index 000000000..c02991ace --- /dev/null +++ b/app/lib/rss/item.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class RSS::Item < RSS::Element + def initialize + super() + + @root = create_element('item') + end + + def title(str) + append_element('title', str) + end + + def link(str) + append_element('guid', str) do |guid| + guid['isPermaLink'] = 'true' + end + + append_element('link', str) + end + + def pub_date(date) + append_element('pubDate', date.to_formatted_s(:rfc822)) + end + + def description(str) + append_element('description', str) + end + + def category(str) + append_element('category', str) + end + + def enclosure(url, type, size) + append_element('enclosure') do |enclosure| + enclosure['url'] = url + enclosure['length'] = size + enclosure['type'] = type + end + end + + def media_content(url, type, size, &block) + @root << RSS::MediaContent.with(url, type, size, &block) + end +end diff --git a/app/lib/rss/media_content.rb b/app/lib/rss/media_content.rb new file mode 100644 index 000000000..7aefd8b40 --- /dev/null +++ b/app/lib/rss/media_content.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class RSS::MediaContent < RSS::Element + def initialize(url, type, size) + super() + + @root = create_element('media:content') do |content| + content['url'] = url + content['type'] = type + content['fileSize'] = size + end + end + + def medium(str) + @root['medium'] = str + end + + def rating(str) + append_element('media:rating', str) do |rating| + rating['scheme'] = 'urn:simple' + end + end + + def description(str) + append_element('media:description', str) do |description| + description['type'] = 'plain' + end + end +end diff --git a/app/lib/rss/serializer.rb b/app/lib/rss/serializer.rb deleted file mode 100644 index d44e94221..000000000 --- a/app/lib/rss/serializer.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -class RSS::Serializer - include FormattingHelper - - private - - def render_statuses(builder, statuses) - statuses.each do |status| - builder.item do |item| - item.title(status_title(status)) - .link(ActivityPub::TagManager.instance.url_for(status)) - .pub_date(status.created_at) - .description(status_description(status)) - - status.ordered_media_attachments.each do |media| - item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) - end - end - end - end - - def status_title(status) - preview = status.proper.spoiler_text.presence || status.proper.text - - if preview.length > 30 || preview[0, 30].include?("\n") - preview = preview[0, 30] - preview = preview[0, preview.index("\n").presence || 30] + '…' - end - - preview = "#{status.proper.spoiler_text.present? ? 'CW ' : ''}“#{preview}”#{status.proper.sensitive? ? ' (sensitive)' : ''}" - - if status.reblog? - "#{status.account.acct} boosted #{status.reblog.account.acct}: #{preview}" - else - "#{status.account.acct}: #{preview}" - end - end - - def status_description(status) - if status.proper.spoiler_text? - status.proper.spoiler_text - else - html = status_content_format(status.proper).to_str - after_html = '' - - if status.proper.preloadable_poll - poll_options_html = status.proper.preloadable_poll.options.map { |o| "[ ] #{o}" }.join('<br />') - after_html = "<p>#{poll_options_html}</p>" - end - - "#{html}#{after_html}" - end - end -end diff --git a/app/lib/rss_builder.rb b/app/lib/rss_builder.rb deleted file mode 100644 index 63ddba2e8..000000000 --- a/app/lib/rss_builder.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -class RSSBuilder - class ItemBuilder - def initialize - @item = Ox::Element.new('item') - end - - def title(str) - @item << (Ox::Element.new('title') << str) - - self - end - - def link(str) - @item << Ox::Element.new('guid').tap do |guid| - guid['isPermalink'] = 'true' - guid << str - end - - @item << (Ox::Element.new('link') << str) - - self - end - - def pub_date(date) - @item << (Ox::Element.new('pubDate') << date.to_formatted_s(:rfc822)) - - self - end - - def description(str) - @item << (Ox::Element.new('description') << str) - - self - end - - def enclosure(url, type, size) - @item << Ox::Element.new('enclosure').tap do |enclosure| - enclosure['url'] = url - enclosure['length'] = size - enclosure['type'] = type - end - - self - end - - def to_element - @item - end - end - - def initialize - @document = Ox::Document.new(version: '1.0') - @channel = Ox::Element.new('channel') - - @document << (rss << @channel) - end - - def title(str) - @channel << (Ox::Element.new('title') << str) - - self - end - - def link(str) - @channel << (Ox::Element.new('link') << str) - - self - end - - def image(str) - @channel << Ox::Element.new('image').tap do |image| - image << (Ox::Element.new('url') << str) - image << (Ox::Element.new('title') << '') - image << (Ox::Element.new('link') << '') - end - - @channel << (Ox::Element.new('webfeeds:icon') << str) - - self - end - - def cover(str) - @channel << Ox::Element.new('webfeeds:cover').tap do |cover| - cover['image'] = str - end - - self - end - - def logo(str) - @channel << (Ox::Element.new('webfeeds:logo') << str) - - self - end - - def accent_color(str) - @channel << (Ox::Element.new('webfeeds:accentColor') << str) - - self - end - - def description(str) - @channel << (Ox::Element.new('description') << str) - - self - end - - def item - @channel << ItemBuilder.new.tap do |item| - yield item - end.to_element - - self - end - - def to_xml - ('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(@document, effort: :tolerant)).force_encoding('UTF-8') - end - - private - - def rss - Ox::Element.new('rss').tap do |rss| - rss['version'] = '2.0' - rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0' - end - end -end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 113e0cca7..e644a3f91 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -13,6 +13,7 @@ class REST::AccountSerializer < ActiveModel::Serializer has_many :emojis, serializer: REST::CustomEmojiSerializer attribute :suspended, if: :suspended? + attribute :silenced, key: :limited, if: :silenced? class FieldSerializer < ActiveModel::Serializer include FormattingHelper @@ -102,7 +103,11 @@ class REST::AccountSerializer < ActiveModel::Serializer object.suspended? end - delegate :suspended?, to: :object + def silenced + object.silenced? + end + + delegate :suspended?, :silenced?, to: :object def moved_and_not_nested? object.moved? && object.moved_to_account.moved_to_account_id.nil? diff --git a/app/serializers/rss/account_serializer.rb b/app/serializers/rss/account_serializer.rb deleted file mode 100644 index 81e24af0d..000000000 --- a/app/serializers/rss/account_serializer.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class RSS::AccountSerializer < RSS::Serializer - include ActionView::Helpers::NumberHelper - include AccountsHelper - include RoutingHelper - - def render(account, statuses, tag) - builder = RSSBuilder.new - - builder.title("#{display_name(account)} (@#{account.local_username_and_domain})") - .description(account_description(account)) - .link(tag.present? ? short_account_tag_url(account, tag) : short_account_url(account)) - .logo(full_pack_url('media/images/logo.svg')) - .accent_color('2b90d9') - - builder.image(full_asset_url(account.avatar.url(:original))) if account.avatar? - builder.cover(full_asset_url(account.header.url(:original))) if account.header? - - render_statuses(builder, statuses) - - builder.to_xml - end - - def self.render(account, statuses, tag) - new.render(account, statuses, tag) - end -end diff --git a/app/serializers/rss/tag_serializer.rb b/app/serializers/rss/tag_serializer.rb deleted file mode 100644 index e549ac367..000000000 --- a/app/serializers/rss/tag_serializer.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class RSS::TagSerializer < RSS::Serializer - include ActionView::Helpers::NumberHelper - include ActionView::Helpers::SanitizeHelper - include RoutingHelper - - def render(tag, statuses) - builder = RSSBuilder.new - - builder.title("##{tag.name}") - .description(strip_tags(I18n.t('about.about_hashtag_html', hashtag: tag.name))) - .link(tag_url(tag)) - .logo(full_pack_url('media/images/logo.svg')) - .accent_color('2b90d9') - - render_statuses(builder, statuses) - - builder.to_xml - end - - def self.render(tag, statuses) - new.render(tag, statuses) - end -end diff --git a/app/views/accounts/show.rss.ruby b/app/views/accounts/show.rss.ruby new file mode 100644 index 000000000..73c1c51e0 --- /dev/null +++ b/app/views/accounts/show.rss.ruby @@ -0,0 +1,37 @@ +RSS::Builder.build do |doc| + doc.title(display_name(@account)) + doc.description(I18n.t('rss.descriptions.account', acct: @account.local_username_and_domain)) + doc.link(params[:tag].present? ? short_account_tag_url(@account, params[:tag]) : short_account_url(@account)) + doc.image(full_asset_url(@account.avatar.url(:original)), display_name(@account), params[:tag].present? ? short_account_tag_url(@account, params[:tag]) : short_account_url(@account)) + doc.last_build_date(@statuses.first.created_at) if @statuses.any? + doc.icon(full_asset_url(@account.avatar.url(:original))) + doc.logo(full_pack_url('media/images/logo_transparent_white.svg')) + doc.generator("Mastodon v#{Mastodon::Version.to_s}") + + @statuses.each do |status| + doc.item do |item| + item.title(l(status.created_at)) + item.link(ActivityPub::TagManager.instance.url_for(status)) + item.pub_date(status.created_at) + item.description(rss_status_content_format(status)) + + if status.ordered_media_attachments.first&.audio? + media = status.ordered_media_attachments.first + item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) + end + + status.ordered_media_attachments.each do |media| + item.media_content(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) do |media_content| + media_content.medium(media.gifv? ? 'image' : media.type.to_s) + media_content.rating(status.sensitive? ? 'adult' : 'nonadult') + media_content.description(media.description) if media.description.present? + media_content.thumbnail(media.thumbnail.url(:original, false)) if media.thumbnail? + end + end + + status.tags.each do |tag| + item.category(tag.name) + end + end + end +end diff --git a/app/views/tags/show.rss.ruby b/app/views/tags/show.rss.ruby new file mode 100644 index 000000000..4152ecd24 --- /dev/null +++ b/app/views/tags/show.rss.ruby @@ -0,0 +1,36 @@ +RSS::Builder.build do |doc| + doc.title("##{@tag.name}") + doc.description(I18n.t('rss.descriptions.tag', hashtag: @tag.name)) + doc.link(tag_url(@tag)) + doc.last_build_date(@statuses.first.created_at) if @statuses.any? + doc.icon(full_asset_url(@account.avatar.url(:original))) + doc.logo(full_pack_url('media/images/logo_transparent_white.svg')) + doc.generator("Mastodon v#{Mastodon::Version.to_s}") + + @statuses.each do |status| + doc.item do |item| + item.title(l(status.created_at)) + item.link(ActivityPub::TagManager.instance.url_for(status)) + item.pub_date(status.created_at) + item.description(rss_status_content_format(status)) + + if status.ordered_media_attachments.first&.audio? + media = status.ordered_media_attachments.first + item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) + end + + status.ordered_media_attachments.each do |media| + item.media_content(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) do |media_content| + media_content.medium(media.gifv? ? 'image' : media.type.to_s) + media_content.rating(status.sensitive? ? 'adult' : 'nonadult') + media_content.description(media.description) if media.description.present? + media_content.thumbnail(media.thumbnail.url(:original, false)) if media.thumbnail? + end + end + + status.tags.each do |tag| + item.category(tag.name) + end + end + end +end |