Merge pull request #1767 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes
signup-info-prompt
Claire 2022-05-11 12:14:29 +02:00 committed by GitHub
commit 32762f2fa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1075 additions and 904 deletions

View File

@ -114,7 +114,7 @@ group :production, :test do
end end
group :test do group :test do
gem 'capybara', '~> 3.36' gem 'capybara', '~> 3.37'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.20' gem 'faker', '~> 2.20'
gem 'microformats', '~> 4.2' gem 'microformats', '~> 4.2'

View File

@ -144,7 +144,7 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (3.36.0) capybara (3.37.1)
addressable addressable
matrix matrix
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
@ -308,7 +308,7 @@ GEM
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.10.0) i18n (1.10.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-tasks (1.0.9) i18n-tasks (1.0.10)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
ast (>= 2.1.0) ast (>= 2.1.0)
better_html (~> 1.0) better_html (~> 1.0)
@ -469,7 +469,7 @@ GEM
pry (~> 0.13.0) pry (~> 0.13.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.7)
puma (5.6.4) puma (5.6.4)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
@ -536,7 +536,7 @@ GEM
redis (4.5.1) redis (4.5.1)
redis-namespace (1.8.2) redis-namespace (1.8.2)
redis (>= 3.0.4) redis (>= 3.0.4)
regexp_parser (2.3.1) regexp_parser (2.4.0)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
responders (3.0.1) responders (3.0.1)
@ -614,7 +614,7 @@ GEM
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 4) sidekiq (>= 4)
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.21) sidekiq-unique-jobs (7.1.22)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0) brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 5.0, < 8.0) sidekiq (>= 5.0, < 8.0)
@ -745,7 +745,7 @@ DEPENDENCIES
capistrano-rails (~> 1.6) capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2) capistrano-rbenv (~> 2.2)
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 3.36) capybara (~> 3.37)
charlock_holmes (~> 0.7.7) charlock_holmes (~> 0.7.7)
chewy (~> 7.2) chewy (~> 7.2)
climate_control (~> 0.2) climate_control (~> 0.2)

View File

@ -45,7 +45,6 @@ class AccountsController < ApplicationController
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = filtered_statuses.without_reblogs.limit(limit) @statuses = filtered_statuses.without_reblogs.limit(limit)
@statuses = cache_collection(@statuses, Status) @statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end end
format.json do format.json do

View File

@ -27,7 +27,6 @@ class TagsController < ApplicationController
format.rss do format.rss do
expires_in 0, public: true expires_in 0, public: true
render xml: RSS::TagSerializer.render(@tag, @statuses)
end end
format.json do format.json do

View File

@ -244,7 +244,7 @@ module ApplicationHelper
end.values end.values
end end
def prerender_custom_emojis(html, custom_emojis) def prerender_custom_emojis(html, custom_emojis, other_options = {})
EmojiFormatter.new(html, custom_emojis, animate: prefers_autoplay?).to_s EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s
end end
end end

View File

@ -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) 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 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) def account_bio_format(account)
html_aware_format(account.note, account.local?) html_aware_format(account.note, account.local?)
end end

View File

@ -88,6 +88,8 @@ export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR
export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET'; export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET';
export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
export function fetchAccount(id) { export function fetchAccount(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchRelationships([id])); dispatch(fetchRelationships([id]));
@ -798,6 +800,11 @@ export function unpinAccountFail(error) {
}; };
}; };
export const revealAccount = id => ({
type: ACCOUNT_REVEAL,
id,
});
export function fetchPinnedAccounts() { export function fetchPinnedAccounts() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchPinnedAccountsRequest()); dispatch(fetchPinnedAccountsRequest());

View File

@ -16,8 +16,10 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' }, mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' }, 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 export default @injectIntl
@ -34,6 +36,7 @@ class Account extends ImmutablePureComponent {
small: PropTypes.bool, small: PropTypes.bool,
actionIcon: PropTypes.string, actionIcon: PropTypes.string,
actionTitle: PropTypes.string, actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func, onActionClick: PropTypes.func,
}; };
@ -70,6 +73,7 @@ class Account extends ImmutablePureComponent {
onActionClick, onActionClick,
actionIcon, actionIcon,
actionTitle, actionTitle,
defaultAction,
} = this.props; } = this.props;
if (!account) { if (!account) {
@ -114,6 +118,10 @@ class Account extends ImmutablePureComponent {
{hidingNotificationsButton} {hidingNotificationsButton}
</Fragment> </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) { } 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} />; buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
} }

View File

@ -1,13 +1,13 @@
import classNames from 'classnames';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/util/initial_state'; import { autoPlayGif } from 'flavours/glitch/util/initial_state';
import classNames from 'classnames';
export default class Avatar extends React.PureComponent { export default class Avatar extends React.PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map,
className: PropTypes.string, className: PropTypes.string,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
style: PropTypes.object, style: PropTypes.object,
@ -45,11 +45,6 @@ export default class Avatar extends React.PureComponent {
} = this.props; } = this.props;
const { hovering } = this.state; const { hovering } = this.state;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
const style = { const style = {
...this.props.style, ...this.props.style,
width: `${size}px`, width: `${size}px`,
@ -57,19 +52,24 @@ export default class Avatar extends React.PureComponent {
backgroundSize: `${size}px ${size}px`, backgroundSize: `${size}px ${size}px`,
}; };
if (hovering || animate) { if (account) {
style.backgroundImage = `url(${src})`; const src = account.get('avatar');
} else { const staticSrc = account.get('avatar_static');
style.backgroundImage = `url(${staticSrc})`;
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
}
} }
return ( return (
<div <div
className={computedClass} className={classNames('account__avatar', { 'account__avatar-inline': inline }, className)}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
style={style} style={style}
data-avatar-of={`@${account.get('acct')}`} data-avatar-of={account && `@${account.get('acct')}`}
/> />
); );
} }

View File

@ -82,6 +82,7 @@ class Header extends ImmutablePureComponent {
onEditAccountNote: PropTypes.func.isRequired, onEditAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
}; };
openEditProfile = () => { openEditProfile = () => {
@ -115,7 +116,7 @@ class Header extends ImmutablePureComponent {
} }
render () { render () {
const { account, intl, domain, identity_proofs } = this.props; const { account, hidden, intl, domain } = this.props;
if (!account) { if (!account) {
return null; return null;
@ -270,23 +271,29 @@ class Header extends ImmutablePureComponent {
{info} {info}
</div> </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>
<div className='account__header__bar'> <div className='account__header__bar'>
<div className='account__header__tabs'> <div className='account__header__tabs'>
<a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'> <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> </a>
<div className='spacer' /> <div className='spacer' />
<div className='account__header__tabs__buttons'> {!suspended && (
{actionBtn} <div className='account__header__tabs__buttons'>
{bellBtn} {!hidden && (
<React.Fragment>
{actionBtn}
{bellBtn}
</React.Fragment>
)}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div> </div>
)}
</div> </div>
<div className='account__header__tabs__name'> <div className='account__header__tabs__name'>
@ -298,23 +305,11 @@ class Header extends ImmutablePureComponent {
<AccountNoteContainer account={account} /> <AccountNoteContainer account={account} />
{!suspended && ( {!(suspended || hidden) && (
<div className='account__header__extra'> <div className='account__header__extra'>
<div className='account__header__bio'> <div className='account__header__bio'>
{ (fields.size > 0 || identity_proofs.size > 0) && ( { fields.size > 0 && (
<div className='account__header__fields'> <div className='account__header__fields'>
{identity_proofs.map((proof, i) => (
<dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} className='translate' />
<dd className='verified'>
<a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
<Icon id='check' className='verified__mark' />
</span></a>
<a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} className='translate' /></a>
</dd>
</dl>
))}
{fields.map((pair, i) => ( {fields.map((pair, i) => (
<dl key={i}> <dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} /> <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />

View File

@ -12,7 +12,6 @@ export default class Header extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
identity_proofs: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,
@ -26,6 +25,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired,
hideTabs: PropTypes.bool, hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
}; };
static contextTypes = { static contextTypes = {
@ -93,7 +93,7 @@ export default class Header extends ImmutablePureComponent {
} }
render () { render () {
const { account, hideTabs, identity_proofs } = this.props; const { account, hidden, hideTabs } = this.props;
if (account === null) { if (account === null) {
return null; return null;
@ -101,11 +101,10 @@ export default class Header extends ImmutablePureComponent {
return ( return (
<div className='account-timeline__header'> <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 <InnerHeader
account={account} account={account}
identity_proofs={identity_proofs}
onFollow={this.handleFollow} onFollow={this.handleFollow}
onBlock={this.handleBlock} onBlock={this.handleBlock}
onMention={this.handleMention} onMention={this.handleMention}
@ -120,13 +119,14 @@ export default class Header extends ImmutablePureComponent {
onAddToList={this.handleAddToList} onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote} onEditAccountNote={this.handleEditAccountNote}
domain={this.props.domain} domain={this.props.domain}
hidden={hidden}
/> />
<ActionBar <ActionBar
account={account} account={account}
/> />
{!hideTabs && ( {!(hideTabs || hidden) && (
<div className='account__section-headline'> <div className='account__section-headline'>
<NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink> <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 with replies' /></NavLink> <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts with replies' /></NavLink>

View File

@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { revealAccount } from 'flavours/glitch/actions/accounts';
import { FormattedMessage } from 'react-intl';
import Button from 'flavours/glitch/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>
);
}
}

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors'; import { makeGetAccount, getAccountHidden } from 'flavours/glitch/selectors';
import Header from '../components/header'; import Header from '../components/header';
import { import {
followAccount, followAccount,
@ -22,7 +22,6 @@ import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_block
import { initEditAccountNote } from 'flavours/glitch/actions/account_notes'; import { initEditAccountNote } from 'flavours/glitch/actions/account_notes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from 'flavours/glitch/util/initial_state'; import { unfollowModal } from 'flavours/glitch/util/initial_state';
import { List as ImmutableList } from 'immutable';
const messages = defineMessages({ const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@ -35,7 +34,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, { accountId }) => ({ const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId), account: getAccount(state, accountId),
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()), hidden: getAccountHidden(state, accountId),
}); });
return mapStateToProps; return mapStateToProps;

View File

@ -13,9 +13,10 @@ import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import TimelineHint from 'flavours/glitch/components/timeline_hint'; import TimelineHint from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
const emptyList = ImmutableList(); const emptyList = ImmutableList();
@ -40,6 +41,7 @@ const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) =
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false), suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hidden: getAccountHidden(state, accountId),
}; };
}; };
@ -68,6 +70,7 @@ class AccountTimeline extends ImmutablePureComponent {
withReplies: PropTypes.bool, withReplies: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool, suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -77,7 +80,7 @@ class AccountTimeline extends ImmutablePureComponent {
const { accountId, withReplies, dispatch } = this.props; const { accountId, withReplies, dispatch } = this.props;
dispatch(fetchAccount(accountId)); dispatch(fetchAccount(accountId));
dispatch(fetchAccountIdentityProofs(accountId));
if (!withReplies) { if (!withReplies) {
dispatch(expandAccountFeaturedTimeline(accountId)); dispatch(expandAccountFeaturedTimeline(accountId));
} }
@ -109,10 +112,11 @@ class AccountTimeline extends ImmutablePureComponent {
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
dispatch(fetchAccount(nextProps.params.accountId)); dispatch(fetchAccount(nextProps.params.accountId));
dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
if (!nextProps.withReplies) { if (!nextProps.withReplies) {
dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId)); dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
} }
dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies })); dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
} }
} }
@ -130,7 +134,7 @@ class AccountTimeline extends ImmutablePureComponent {
} }
render () { render () {
const { statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props; const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -151,8 +155,12 @@ class AccountTimeline extends ImmutablePureComponent {
let emptyMessage; let emptyMessage;
const forceEmptyState = suspended || hidden;
if (suspended) { if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (remote && statusIds.isEmpty()) { } else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;
} else { } else {
@ -166,14 +174,14 @@ class AccountTimeline extends ImmutablePureComponent {
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<StatusList <StatusList
prepend={<HeaderContainer accountId={this.props.accountId} />} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
scrollKey='account_timeline' scrollKey='account_timeline'
statusIds={suspended ? emptyList : statusIds} statusIds={forceEmptyState ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds} featuredStatusIds={featuredStatusIds}
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={!forceEmptyState && hasMore}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}

View File

@ -69,7 +69,7 @@ class Blocks extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{accountIds.map(id => {accountIds.map(id =>
<AccountContainer key={id} id={id} />, <AccountContainer key={id} id={id} defaultAction='block' />,
)} )}
</ScrollableList> </ScrollableList>
</Column> </Column>

View File

@ -19,6 +19,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint'; import TimelineHint from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
const mapStateToProps = (state, { params: { acct, id } }) => { const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', acct]); 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']), accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']), hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true), isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hidden: getAccountHidden(state, accountId),
}; };
}; };
@ -62,6 +66,8 @@ class Followers extends ImmutablePureComponent {
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -107,7 +113,7 @@ class Followers extends ImmutablePureComponent {
} }
render () { render () {
const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -127,7 +133,13 @@ class Followers extends ImmutablePureComponent {
let emptyMessage; let emptyMessage;
if (remote && accountIds.isEmpty()) { const forceEmptyState = suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;
} else { } else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />; emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
@ -141,7 +153,7 @@ class Followers extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='followers' scrollKey='followers'
hasMore={hasMore} hasMore={!forceEmptyState && hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}

View File

@ -19,6 +19,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint'; import TimelineHint from 'flavours/glitch/components/timeline_hint';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
import { getAccountHidden } from 'flavours/glitch/selectors';
const mapStateToProps = (state, { params: { acct, id } }) => { const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', acct]); 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']), accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']), hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true), isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hidden: getAccountHidden(state, accountId),
}; };
}; };
@ -62,6 +66,8 @@ class Following extends ImmutablePureComponent {
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -107,7 +113,7 @@ class Following extends ImmutablePureComponent {
} }
render () { render () {
const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props; const { accountId, accountIds, hasMore, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -127,7 +133,13 @@ class Following extends ImmutablePureComponent {
let emptyMessage; let emptyMessage;
if (remote && accountIds.isEmpty()) { const forceEmptyState = suspended || hidden;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;
} else { } else {
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />; emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
@ -141,7 +153,7 @@ class Following extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='following' scrollKey='following'
hasMore={hasMore} hasMore={!forceEmptyState && hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}

View File

@ -69,7 +69,7 @@ class Mutes extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{accountIds.map(id => {accountIds.map(id =>
<AccountContainer key={id} id={id} />, <AccountContainer key={id} id={id} defaultAction='mute' />,
)} )}
</ScrollableList> </ScrollableList>
</Column> </Column>

View File

@ -1,4 +1,5 @@
import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer'; import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'flavours/glitch/actions/importer';
import { ACCOUNT_REVEAL } from 'flavours/glitch/actions/accounts';
import { Map as ImmutableMap, fromJS } from 'immutable'; import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@ -10,6 +11,8 @@ const normalizeAccount = (state, account) => {
delete account.following_count; delete account.following_count;
delete account.statuses_count; delete account.statuses_count;
account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited;
return state.set(account.id, fromJS(account)); return state.set(account.id, fromJS(account));
}; };
@ -27,6 +30,8 @@ export default function accounts(state = initialState, action) {
return normalizeAccount(state, action.account); return normalizeAccount(state, action.account);
case ACCOUNTS_IMPORT: case ACCOUNTS_IMPORT:
return normalizeAccounts(state, action.accounts); return normalizeAccounts(state, action.accounts);
case ACCOUNT_REVEAL:
return state.setIn([action.id, 'hidden'], false);
default: default:
return state; return state;
} }

View File

@ -1,25 +0,0 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import {
IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
} from '../actions/identity_proofs';
const initialState = ImmutableMap();
export default function identityProofsReducer(state = initialState, action) {
switch(action.type) {
case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST:
return state.set('isLoading', true);
case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL:
return state.set('isLoading', false);
case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS:
return state.update(identity_proofs => identity_proofs.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set(action.accountId, fromJS(action.identity_proofs));
}));
default:
return state;
}
};

View File

@ -34,7 +34,6 @@ import conversations from './conversations';
import suggestions from './suggestions'; import suggestions from './suggestions';
import pinnedAccountsEditor from './pinned_accounts_editor'; import pinnedAccountsEditor from './pinned_accounts_editor';
import polls from './polls'; import polls from './polls';
import identity_proofs from './identity_proofs';
import trends from './trends'; import trends from './trends';
import announcements from './announcements'; import announcements from './announcements';
import markers from './markers'; import markers from './markers';
@ -73,7 +72,6 @@ const reducers = {
notifications, notifications,
height_cache, height_cache,
custom_emojis, custom_emojis,
identity_proofs,
lists, lists,
listEditor, listEditor,
listAdder, listAdder,

View File

@ -194,3 +194,11 @@ export const getAccountGallery = createSelector([
return medias; 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);
});

View File

@ -560,6 +560,15 @@
} }
} }
.limited-account-hint {
p {
color: $secondary-text-color;
font-size: 15px;
font-weight: 500;
margin-bottom: 20px;
}
}
.empty-column-indicator, .empty-column-indicator,
.error-column, .error-column,
.follow_requests-unlocked_explanation { .follow_requests-unlocked_explanation {

View File

@ -1022,68 +1022,6 @@ code {
} }
} }
.connection-prompt {
margin-bottom: 25px;
.fa-link {
background-color: darken($ui-base-color, 4%);
border-radius: 100%;
font-size: 24px;
padding: 10px;
}
&__column {
align-items: center;
display: flex;
flex: 1;
flex-direction: column;
flex-shrink: 1;
max-width: 50%;
&-sep {
align-self: center;
flex-grow: 0;
overflow: visible;
position: relative;
z-index: 1;
}
p {
word-break: break-word;
}
}
.account__avatar {
margin-bottom: 20px;
}
&__connection {
background-color: lighten($ui-base-color, 8%);
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
border-radius: 4px;
padding: 25px 10px;
position: relative;
text-align: center;
&::after {
background-color: darken($ui-base-color, 4%);
content: '';
display: block;
height: 100%;
left: 50%;
position: absolute;
top: 0;
width: 1px;
}
}
&__row {
align-items: flex-start;
display: flex;
flex-direction: row;
}
}
.input.user_confirm_password, .input.user_confirm_password,
.input.user_website { .input.user_website {
&:not(.field_with_errors) { &:not(.field_with_errors) {

View File

@ -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_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
export function fetchAccount(id) { export function fetchAccount(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchRelationships([id])); dispatch(fetchRelationships([id]));
@ -780,3 +782,8 @@ export function unpinAccountFail(error) {
error, error,
}; };
}; };
export const revealAccount = id => ({
type: ACCOUNT_REVEAL,
id,
});

View File

@ -18,6 +18,8 @@ const messages = defineMessages({
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute 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 export default @injectIntl
@ -33,6 +35,7 @@ class Account extends ImmutablePureComponent {
hidden: PropTypes.bool, hidden: PropTypes.bool,
actionIcon: PropTypes.string, actionIcon: PropTypes.string,
actionTitle: PropTypes.string, actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func, onActionClick: PropTypes.func,
}; };
@ -61,7 +64,7 @@ class Account extends ImmutablePureComponent {
} }
render () { render () {
const { account, intl, hidden, onActionClick, actionIcon, actionTitle } = this.props; const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction } = this.props;
if (!account) { if (!account) {
return <div />; return <div />;
@ -105,6 +108,10 @@ class Account extends ImmutablePureComponent {
{hidingNotificationsButton} {hidingNotificationsButton}
</Fragment> </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) { } 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} />; buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
} }

View File

@ -2,11 +2,12 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
import classNames from 'classnames';
export default class Avatar extends React.PureComponent { export default class Avatar extends React.PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
style: PropTypes.object, style: PropTypes.object,
inline: PropTypes.bool, inline: PropTypes.bool,
@ -37,15 +38,6 @@ export default class Avatar extends React.PureComponent {
const { account, size, animate, inline } = this.props; const { account, size, animate, inline } = this.props;
const { hovering } = this.state; 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 = { const style = {
...this.props.style, ...this.props.style,
width: `${size}px`, width: `${size}px`,
@ -53,15 +45,21 @@ export default class Avatar extends React.PureComponent {
backgroundSize: `${size}px ${size}px`, backgroundSize: `${size}px ${size}px`,
}; };
if (hovering || animate) { if (account) {
style.backgroundImage = `url(${src})`; const src = account.get('avatar');
} else { const staticSrc = account.get('avatar_static');
style.backgroundImage = `url(${staticSrc})`;
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
}
} }
return ( return (
<div <div
className={className} className={classNames('account__avatar', { 'account__avatar-inline': inline })}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
style={style} style={style}

View File

@ -82,6 +82,7 @@ class Header extends ImmutablePureComponent {
onEditAccountNote: PropTypes.func.isRequired, onEditAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
}; };
openEditProfile = () => { openEditProfile = () => {
@ -123,7 +124,7 @@ class Header extends ImmutablePureComponent {
} }
render () { render () {
const { account, intl, domain } = this.props; const { account, hidden, intl, domain } = this.props;
if (!account) { if (!account) {
return null; return null;
@ -267,21 +268,25 @@ class Header extends ImmutablePureComponent {
{!suspended && info} {!suspended && info}
</div> </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>
<div className='account__header__bar'> <div className='account__header__bar'>
<div className='account__header__tabs'> <div className='account__header__tabs'>
<a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'> <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> </a>
<div className='spacer' /> <div className='spacer' />
{!suspended && ( {!suspended && (
<div className='account__header__tabs__buttons'> <div className='account__header__tabs__buttons'>
{actionBtn} {!hidden && (
{bellBtn} <React.Fragment>
{actionBtn}
{bellBtn}
</React.Fragment>
)}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div> </div>
@ -295,30 +300,30 @@ class Header extends ImmutablePureComponent {
</h1> </h1>
</div> </div>
<div className='account__header__extra'> {!(suspended || hidden) && (
<div className='account__header__bio'> <div className='account__header__extra'>
{fields.size > 0 && ( <div className='account__header__bio'>
<div className='account__header__fields'> {fields.size > 0 && (
{fields.map((pair, i) => ( <div className='account__header__fields'>
<dl key={i}> {fields.map((pair, i) => (
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' /> <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')}> <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') }} /> {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> </dd>
</dl> </dl>
))} ))}
</div> </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 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>
{!suspended && (
<div className='account__header__extra__links'> <div className='account__header__extra__links'>
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}> <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<ShortNumber <ShortNumber
@ -341,8 +346,8 @@ class Header extends ImmutablePureComponent {
/> />
</NavLink> </NavLink>
</div> </div>
)} </div>
</div> )}
</div> </div>
</div> </div>
); );

View File

@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent {
onAddToList: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired,
hideTabs: PropTypes.bool, hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
}; };
static contextTypes = { static contextTypes = {
@ -91,7 +92,7 @@ export default class Header extends ImmutablePureComponent {
} }
render () { render () {
const { account, hideTabs } = this.props; const { account, hidden, hideTabs } = this.props;
if (account === null) { if (account === null) {
return null; return null;
@ -99,7 +100,7 @@ export default class Header extends ImmutablePureComponent {
return ( return (
<div className='account-timeline__header'> <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 <InnerHeader
account={account} account={account}
@ -117,9 +118,10 @@ export default class Header extends ImmutablePureComponent {
onAddToList={this.handleAddToList} onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote} onEditAccountNote={this.handleEditAccountNote}
domain={this.props.domain} domain={this.props.domain}
hidden={hidden}
/> />
{!hideTabs && ( {!(hideTabs || hidden) && (
<div className='account__section-headline'> <div className='account__section-headline'>
<NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink> <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> <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' /></NavLink>

View File

@ -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>
);
}
}

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors'; import { makeGetAccount, getAccountHidden } from '../../../selectors';
import Header from '../components/header'; import Header from '../components/header';
import { import {
followAccount, followAccount,
@ -33,6 +33,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, { accountId }) => ({ const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId), account: getAccount(state, accountId),
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
hidden: getAccountHidden(state, accountId),
}); });
return mapStateToProps; return mapStateToProps;

View File

@ -16,6 +16,8 @@ import MissingIndicator from 'mastodon/components/missing_indicator';
import TimelineHint from 'mastodon/components/timeline_hint'; import TimelineHint from 'mastodon/components/timeline_hint';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines'; import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
import LimitedAccountHint from './components/limited_account_hint';
import { getAccountHidden } from 'mastodon/selectors';
const emptyList = ImmutableList(); const emptyList = ImmutableList();
@ -40,6 +42,7 @@ const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) =
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false), suspended: state.getIn(['accounts', accountId, 'suspended'], false),
hidden: getAccountHidden(state, accountId),
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
}; };
}; };
@ -70,6 +73,7 @@ class AccountTimeline extends ImmutablePureComponent {
blockedBy: PropTypes.bool, blockedBy: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool, suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -128,7 +132,7 @@ class AccountTimeline extends ImmutablePureComponent {
} }
render () { 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) { if (!isAccount) {
return ( return (
@ -149,8 +153,12 @@ class AccountTimeline extends ImmutablePureComponent {
let emptyMessage; let emptyMessage;
const forceEmptyState = suspended || blockedBy || hidden;
if (suspended) { if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />; emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) { } else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && statusIds.isEmpty()) { } else if (remote && statusIds.isEmpty()) {
@ -166,14 +174,14 @@ class AccountTimeline extends ImmutablePureComponent {
<ColumnBackButton multiColumn={multiColumn} /> <ColumnBackButton multiColumn={multiColumn} />
<StatusList <StatusList
prepend={<HeaderContainer accountId={this.props.accountId} />} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
scrollKey='account_timeline' scrollKey='account_timeline'
statusIds={(suspended || blockedBy) ? emptyList : statusIds} statusIds={forceEmptyState ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds} featuredStatusIds={featuredStatusIds}
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={!forceEmptyState && hasMore}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}

View File

@ -69,7 +69,7 @@ class Blocks extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{accountIds.map(id => {accountIds.map(id =>
<AccountContainer key={id} id={id} />, <AccountContainer key={id} id={id} defaultAction='block' />,
)} )}
</ScrollableList> </ScrollableList>
</Column> </Column>

View File

@ -19,6 +19,8 @@ import ColumnBackButton from '../../components/column_back_button';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import MissingIndicator from 'mastodon/components/missing_indicator'; import MissingIndicator from 'mastodon/components/missing_indicator';
import TimelineHint from 'mastodon/components/timeline_hint'; 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 mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', acct]); 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']), accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']), hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true), 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), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
}; };
}; };
@ -64,6 +68,8 @@ class Followers extends ImmutablePureComponent {
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
blockedBy: PropTypes.bool, blockedBy: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -101,7 +107,7 @@ class Followers extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { 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) { if (!isAccount) {
return ( return (
@ -121,7 +127,13 @@ class Followers extends ImmutablePureComponent {
let emptyMessage; 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' />; emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && accountIds.isEmpty()) { } else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;
@ -137,7 +149,7 @@ class Followers extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='followers' scrollKey='followers'
hasMore={hasMore} hasMore={!forceEmptyState && hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
@ -146,7 +158,7 @@ class Followers extends ImmutablePureComponent {
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{blockedBy ? [] : accountIds.map(id => {forceEmptyState ? [] : accountIds.map(id =>
<AccountContainer key={id} id={id} withNote={false} />, <AccountContainer key={id} id={id} withNote={false} />,
)} )}
</ScrollableList> </ScrollableList>

View File

@ -19,6 +19,8 @@ import ColumnBackButton from '../../components/column_back_button';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import MissingIndicator from 'mastodon/components/missing_indicator'; import MissingIndicator from 'mastodon/components/missing_indicator';
import TimelineHint from 'mastodon/components/timeline_hint'; 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 mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', acct]); 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']), accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']), hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true), 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), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
}; };
}; };
@ -64,6 +68,8 @@ class Following extends ImmutablePureComponent {
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
blockedBy: PropTypes.bool, blockedBy: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool,
hidden: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -101,7 +107,7 @@ class Following extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { 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) { if (!isAccount) {
return ( return (
@ -121,7 +127,13 @@ class Following extends ImmutablePureComponent {
let emptyMessage; 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' />; emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && accountIds.isEmpty()) { } else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;
@ -137,7 +149,7 @@ class Following extends ImmutablePureComponent {
<ScrollableList <ScrollableList
scrollKey='following' scrollKey='following'
hasMore={hasMore} hasMore={!forceEmptyState && hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
@ -146,7 +158,7 @@ class Following extends ImmutablePureComponent {
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{blockedBy ? [] : accountIds.map(id => {forceEmptyState ? [] : accountIds.map(id =>
<AccountContainer key={id} id={id} withNote={false} />, <AccountContainer key={id} id={id} withNote={false} />,
)} )}
</ScrollableList> </ScrollableList>

View File

@ -69,7 +69,7 @@ class Mutes extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{accountIds.map(id => {accountIds.map(id =>
<AccountContainer key={id} id={id} />, <AccountContainer key={id} id={id} defaultAction='mute' />,
)} )}
</ScrollableList> </ScrollableList>
</Column> </Column>

View File

@ -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'; import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@ -10,6 +11,8 @@ const normalizeAccount = (state, account) => {
delete account.following_count; delete account.following_count;
delete account.statuses_count; delete account.statuses_count;
account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited;
return state.set(account.id, fromJS(account)); return state.set(account.id, fromJS(account));
}; };
@ -27,6 +30,8 @@ export default function accounts(state = initialState, action) {
return normalizeAccount(state, action.account); return normalizeAccount(state, action.account);
case ACCOUNTS_IMPORT: case ACCOUNTS_IMPORT:
return normalizeAccounts(state, action.accounts); return normalizeAccounts(state, action.accounts);
case ACCOUNT_REVEAL:
return state.setIn([action.id, 'hidden'], false);
default: default:
return state; return state;
} }

View File

@ -175,3 +175,11 @@ export const getAccountGallery = createSelector([
return medias; 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);
});

View File

@ -4037,6 +4037,15 @@ a.status-card.compact:hover {
vertical-align: middle; vertical-align: middle;
} }
.limited-account-hint {
p {
color: $secondary-text-color;
font-size: 15px;
font-weight: 500;
margin-bottom: 20px;
}
}
.empty-column-indicator, .empty-column-indicator,
.error-column, .error-column,
.follow_requests-unlocked_explanation { .follow_requests-unlocked_explanation {

View File

@ -11,6 +11,7 @@ class EmojiFormatter
# @param [Array<CustomEmoji>] custom_emojis # @param [Array<CustomEmoji>] custom_emojis
# @param [Hash] options # @param [Hash] options
# @option options [Boolean] :animate # @option options [Boolean] :animate
# @option options [String] :style
def initialize(html, custom_emojis, options = {}) def initialize(html, custom_emojis, options = {})
raise ArgumentError unless html.html_safe? raise ArgumentError unless html.html_safe?
@ -85,14 +86,29 @@ class EmojiFormatter
def image_for_emoji(shortcode, emoji) def image_for_emoji(shortcode, emoji)
original_url, static_url = emoji original_url, static_url = emoji
if animate? image_tag(
image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:") animate? ? original_url : static_url,
else image_attributes.merge(alt: ":#{shortcode}:", title: ":#{shortcode}:", data: image_data_attributes(original_url, static_url))
image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url }) )
end 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 end
def animate? def animate?
@options[:animate] @options[:animate] || @options.key?(:style)
end end
end end

33
app/lib/rss/builder.rb Normal file
View File

@ -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

49
app/lib/rss/channel.rb Normal file
View File

@ -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

24
app/lib/rss/element.rb Normal file
View File

@ -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

45
app/lib/rss/item.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -13,6 +13,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
has_many :emojis, serializer: REST::CustomEmojiSerializer has_many :emojis, serializer: REST::CustomEmojiSerializer
attribute :suspended, if: :suspended? attribute :suspended, if: :suspended?
attribute :silenced, key: :limited, if: :silenced?
class FieldSerializer < ActiveModel::Serializer class FieldSerializer < ActiveModel::Serializer
include FormattingHelper include FormattingHelper
@ -102,7 +103,11 @@ class REST::AccountSerializer < ActiveModel::Serializer
object.suspended? object.suspended?
end end
delegate :suspended?, to: :object def silenced
object.silenced?
end
delegate :suspended?, :silenced?, to: :object
def moved_and_not_nested? def moved_and_not_nested?
object.moved? && object.moved_to_account.moved_to_account_id.nil? object.moved? && object.moved_to_account.moved_to_account_id.nil?

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1357,6 +1357,11 @@ en:
reports: reports:
errors: errors:
invalid_rules: does not reference valid rules invalid_rules: does not reference valid rules
rss:
content_warning: 'Content warning:'
descriptions:
account: Public posts from @%{acct}
tag: 'Public posts tagged #%{hashtag}'
scheduled_statuses: scheduled_statuses:
over_daily_limit: You have exceeded the limit of %{limit} scheduled posts for today over_daily_limit: You have exceeded the limit of %{limit} scheduled posts for today
over_total_limit: You have exceeded the limit of %{limit} scheduled posts over_total_limit: You have exceeded the limit of %{limit} scheduled posts

View File

@ -8,6 +8,14 @@ namespace :mastodon do
prompt = TTY::Prompt.new prompt = TTY::Prompt.new
env = {} env = {}
# When the application code gets loaded, it runs `lib/mastodon/redis_configuration.rb`.
# This happens before application environment configuration and sets REDIS_URL etc.
# These variables are then used even when REDIS_HOST etc. are changed, so clear them
# out so they don't interfer with our new configuration.
ENV.delete('REDIS_URL')
ENV.delete('CACHE_REDIS_URL')
ENV.delete('SIDEKIQ_REDIS_URL')
begin begin
prompt.say('Your instance is identified by its domain name. Changing it afterward will break things.') prompt.say('Your instance is identified by its domain name. Changing it afterward will break things.')
env['LOCAL_DOMAIN'] = prompt.ask('Domain name:') do |q| env['LOCAL_DOMAIN'] = prompt.ask('Domain name:') do |q|

View File

@ -40,7 +40,7 @@
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^0.5.7", "@github/webauthn-json": "^0.5.7",
"@rails/ujs": "^6.1.5", "@rails/ujs": "^6.1.5",
"array-includes": "^3.1.4", "array-includes": "^3.1.5",
"atrament": "0.2.4", "atrament": "0.2.4",
"arrow-key-navigation": "^1.2.0", "arrow-key-navigation": "^1.2.0",
"autoprefixer": "^9.8.8", "autoprefixer": "^9.8.8",
@ -110,7 +110,7 @@
"react-redux-loading-bar": "^4.0.8", "react-redux-loading-bar": "^4.0.8",
"react-router-dom": "^4.1.1", "react-router-dom": "^4.1.1",
"react-router-scroll-4": "^1.0.0-beta.1", "react-router-scroll-4": "^1.0.0-beta.1",
"react-select": "^5.3.1", "react-select": "^5.3.2",
"react-sparklines": "^1.7.0", "react-sparklines": "^1.7.0",
"react-swipeable-views": "^0.14.0", "react-swipeable-views": "^0.14.0",
"react-textarea-autosize": "^8.3.3", "react-textarea-autosize": "^8.3.3",
@ -147,14 +147,14 @@
"@testing-library/jest-dom": "^5.16.4", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^12.1.5", "@testing-library/react": "^12.1.5",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^28.0.3", "babel-jest": "^28.1.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-import": "~2.26.0", "eslint-plugin-import": "~2.26.0",
"eslint-plugin-jsx-a11y": "~6.5.1", "eslint-plugin-jsx-a11y": "~6.5.1",
"eslint-plugin-promise": "~6.0.0", "eslint-plugin-promise": "~6.0.0",
"eslint-plugin-react": "~7.29.4", "eslint-plugin-react": "~7.29.4",
"jest": "^28.0.3", "jest": "^28.1.0",
"jest-environment-jsdom": "^28.0.2", "jest-environment-jsdom": "^28.1.0",
"prettier": "^2.6.2", "prettier": "^2.6.2",
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",

View File

@ -22,7 +22,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
let(:user) { Fabricate(:user, email: 'local-part@domain', otp_secret: with_otp_secret ? 'oldotpsecret' : nil) } let(:user) { Fabricate(:user, email: 'local-part@domain', otp_secret: with_otp_secret ? 'oldotpsecret' : nil) }
describe 'GET #new' do describe 'GET #new' do
context 'when signed in and a new otp secret has been setted in the session' do context 'when signed in and a new otp secret has been set in the session' do
subject do subject do
sign_in user, scope: :user sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' } get :new, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
@ -36,7 +36,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
expect(response).to redirect_to('/auth/sign_in') expect(response).to redirect_to('/auth/sign_in')
end end
it 'redirects if a new otp_secret has not been setted in the session' do it 'redirects if a new otp_secret has not been set in the session' do
sign_in user, scope: :user sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc } get :new, session: { challenge_passed_at: Time.now.utc }
expect(response).to redirect_to('/settings/otp_authentication') expect(response).to redirect_to('/settings/otp_authentication')

View File

@ -24,7 +24,7 @@ RSpec.describe EmojiFormatter do
let(:text) { preformat_text(':coolcat: Beep boop') } let(:text) { preformat_text(':coolcat: Beep boop') }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) is_expected.to match(/<img rel="emoji" draggable="false" width="16" height="16" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -32,7 +32,7 @@ RSpec.describe EmojiFormatter do
let(:text) { preformat_text('Beep :coolcat: boop') } let(:text) { preformat_text('Beep :coolcat: boop') }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) is_expected.to match(/Beep <img rel="emoji" draggable="false" width="16" height="16" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
@ -48,7 +48,7 @@ RSpec.describe EmojiFormatter do
let(:text) { preformat_text('Beep boop :coolcat:') } let(:text) { preformat_text('Beep boop :coolcat:') }
it 'converts the shortcode to an image tag' do it 'converts the shortcode to an image tag' do
is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) is_expected.to match(/boop <img rel="emoji" draggable="false" width="16" height="16" class="emojione custom-emoji" alt=":coolcat:"/)
end end
end end
end end

View File

@ -1,56 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe RSS::Serializer do
describe '#status_title' do
let(:text) { 'This is a toot' }
let(:spoiler) { '' }
let(:sensitive) { false }
let(:reblog) { nil }
let(:account) { Fabricate(:account) }
let(:status) { Fabricate(:status, account: account, text: text, spoiler_text: spoiler, sensitive: sensitive, reblog: reblog) }
subject { RSS::Serializer.new.send(:status_title, status) }
context 'on a toot with long text' do
let(:text) { "This toot's text is longer than the allowed number of characters" }
it 'truncates toot text appropriately' do
expect(subject).to eq "#{account.acct}: “This toot's text is longer tha…”"
end
end
context 'on a toot with long text with a newline' do
let(:text) { "This toot's text is longer\nthan the allowed number of characters" }
it 'truncates toot text appropriately' do
expect(subject).to eq "#{account.acct}: “This toot's text is longer…”"
end
end
context 'on a toot with a content warning' do
let(:spoiler) { 'long toot' }
it 'displays spoiler text instead of toot content' do
expect(subject).to eq "#{account.acct}: CW “long toot”"
end
end
context 'on a toot with sensitive media' do
let(:sensitive) { true }
it 'displays that the media is sensitive' do
expect(subject).to eq "#{account.acct}: “This is a toot” (sensitive)"
end
end
context 'on a reblog' do
let(:reblog) { Fabricate(:status, text: 'This is a toot') }
it 'display that the toot is a reblog' do
expect(subject).to eq "#{account.acct} boosted #{reblog.account.acct}: “This is a toot”"
end
end
end
end

778
yarn.lock

File diff suppressed because it is too large Load Diff