Change public accounts pages to mount the web UI (#19319)

* Change public accounts pages to mount the web UI

* Fix handling of remote usernames in routes

- When logged in, serve web app
- When logged out, redirect to permalink
- Fix `app-body` class not being set sometimes due to name conflict

* Fix missing `multiColumn` prop

* Fix failing test

* Use `discoverable` attribute to control indexing directives

* Fix `<ColumnLoading />` not using `multiColumn`

* Add `noindex` to accounts in REST API

* Change noindex directive to not be rendered by default before a route is mounted

* Add loading indicator for detailed status in web UI

* Fix missing indicator appearing while account is loading in web UI
th-downstream
Eugen Rochko 2 years ago committed by GitHub
parent fd61882f1a
commit 0fdfbe555e

@ -5,7 +5,15 @@ class AboutController < ApplicationController
skip_before_action :require_functional!
before_action :set_instance_presenter
def show
expires_in 0, public: true unless user_signed_in?
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

@ -1,12 +0,0 @@
# frozen_string_literal: true
class AccountFollowController < ApplicationController
include AccountControllerConcern
before_action :authenticate_user!
def create
FollowService.new.call(current_user.account, @account, with_rate_limit: true)
redirect_to account_path(@account)
end
end

@ -1,12 +0,0 @@
# frozen_string_literal: true
class AccountUnfollowController < ApplicationController
include AccountControllerConcern
before_action :authenticate_user!
def create
UnfollowService.new.call(current_user.account, @account)
redirect_to account_path(@account)
end
end

@ -9,7 +9,6 @@ class AccountsController < ApplicationController
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers
before_action :set_body_classes
skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
skip_before_action :require_functional!, unless: :whitelist_mode?
@ -18,24 +17,6 @@ class AccountsController < ApplicationController
respond_to do |format|
format.html do
expires_in 0, public: true unless user_signed_in?
@pinned_statuses = []
@endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
@featured_hashtags = @account.featured_tags.order(statuses_count: :desc)
if current_account && @account.blocking?(current_account)
@statuses = []
return
end
@pinned_statuses = cached_filtered_status_pins if show_pinned_statuses?
@statuses = cached_filtered_status_page
@rss_url = rss_url
unless @statuses.empty?
@older_url = older_url if @statuses.last.id > filtered_statuses.last.id
@newer_url = newer_url if @statuses.first.id < filtered_statuses.first.id
end
end
format.rss do
@ -55,18 +36,6 @@ class AccountsController < ApplicationController
private
def set_body_classes
@body_classes = 'with-modals'
end
def show_pinned_statuses?
[replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
end
def filtered_pinned_statuses
@account.pinned_statuses.where(visibility: [:public, :unlisted])
end
def filtered_statuses
default_statuses.tap do |statuses|
statuses.merge!(hashtag_scope) if tag_requested?
@ -113,26 +82,6 @@ class AccountsController < ApplicationController
end
end
def older_url
pagination_url(max_id: @statuses.last.id)
end
def newer_url
pagination_url(min_id: @statuses.first.id)
end
def pagination_url(max_id: nil, min_id: nil)
if tag_requested?
short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id)
elsif media_requested?
short_account_media_url(@account, max_id: max_id, min_id: min_id)
elsif replies_requested?
short_account_with_replies_url(@account, max_id: max_id, min_id: min_id)
else
short_account_url(@account, max_id: max_id, min_id: min_id)
end
end
def media_requested?
request.path.split('.').first.end_with?('/media') && !tag_requested?
end
@ -145,13 +94,6 @@ class AccountsController < ApplicationController
request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end
def cached_filtered_status_pins
cache_collection(
filtered_pinned_statuses,
Status
)
end
def cached_filtered_status_page
cache_collection_paginated_by_id(
filtered_statuses,

@ -3,13 +3,12 @@
module AccountControllerConcern
extend ActiveSupport::Concern
include WebAppControllerConcern
include AccountOwnedConcern
FOLLOW_PER_PAGE = 12
included do
layout 'public'
before_action :set_instance_presenter
before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html }
end

@ -4,15 +4,24 @@ module WebAppControllerConcern
extend ActiveSupport::Concern
included do
before_action :set_body_classes
before_action :redirect_unauthenticated_to_permalinks!
before_action :set_app_body_class
before_action :set_referrer_policy_header
end
def set_body_classes
def set_app_body_class
@body_classes = 'app-body'
end
def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin'
end
def redirect_unauthenticated_to_permalinks!
return if user_signed_in?
redirect_path = PermalinkRedirector.new(request.path).redirect_path
redirect_to(redirect_path) if redirect_path.present?
end
end

@ -3,6 +3,7 @@
class FollowerAccountsController < ApplicationController
include AccountControllerConcern
include SignatureVerification
include WebAppControllerConcern
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers
@ -14,10 +15,6 @@ class FollowerAccountsController < ApplicationController
respond_to do |format|
format.html do
expires_in 0, public: true unless user_signed_in?
next if @account.hide_collections?
follows
end
format.json do

@ -3,6 +3,7 @@
class FollowingAccountsController < ApplicationController
include AccountControllerConcern
include SignatureVerification
include WebAppControllerConcern
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers
@ -14,10 +15,6 @@ class FollowingAccountsController < ApplicationController
respond_to do |format|
format.html do
expires_in 0, public: true unless user_signed_in?
next if @account.hide_collections?
follows
end
format.json do

@ -3,21 +3,14 @@
class HomeController < ApplicationController
include WebAppControllerConcern
before_action :redirect_unauthenticated_to_permalinks!
before_action :set_instance_presenter
def index; end
def index
expires_in 0, public: true unless user_signed_in?
end
private
def redirect_unauthenticated_to_permalinks!
return if user_signed_in?
redirect_path = PermalinkRedirector.new(request.path).redirect_path
redirect_to(redirect_path) if redirect_path.present?
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end

@ -5,7 +5,15 @@ class PrivacyController < ApplicationController
skip_before_action :require_functional!
before_action :set_instance_presenter
def show
expires_in 0, public: true if current_account.nil?
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

@ -1,41 +0,0 @@
# frozen_string_literal: true
class RemoteFollowController < ApplicationController
include AccountOwnedConcern
layout 'modal'
before_action :set_body_classes
skip_before_action :require_functional!
def new
@remote_follow = RemoteFollow.new(session_params)
end
def create
@remote_follow = RemoteFollow.new(resource_params)
if @remote_follow.valid?
session[:remote_follow] = @remote_follow.acct
redirect_to @remote_follow.subscribe_address_for(@account)
else
render :new
end
end
private
def resource_params
params.require(:remote_follow).permit(:acct)
end
def session_params
{ acct: session[:remote_follow] || current_account&.username }
end
def set_body_classes
@body_classes = 'modal-layout'
@hide_header = true
end
end

@ -1,55 +0,0 @@
# frozen_string_literal: true
class RemoteInteractionController < ApplicationController
include Authorization
layout 'modal'
before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_interaction_type
before_action :set_status
before_action :set_body_classes
skip_before_action :require_functional!, unless: :whitelist_mode?
def new
@remote_follow = RemoteFollow.new(session_params)
end
def create
@remote_follow = RemoteFollow.new(resource_params)
if @remote_follow.valid?
session[:remote_follow] = @remote_follow.acct
redirect_to @remote_follow.interact_address_for(@status)
else
render :new
end
end
private
def resource_params
params.require(:remote_follow).permit(:acct)
end
def session_params
{ acct: session[:remote_follow] || current_account&.username }
end
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def set_body_classes
@body_classes = 'modal-layout'
@hide_header = true
end
def set_interaction_type
@interaction_type = %w(reply reblog favourite).include?(params[:type]) ? params[:type] : 'reply'
end
end

@ -1,11 +1,11 @@
# frozen_string_literal: true
class StatusesController < ApplicationController
include WebAppControllerConcern
include StatusControllerConcern
include SignatureAuthentication
include Authorization
include AccountOwnedConcern
include WebAppControllerConcern
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status

@ -2,18 +2,16 @@
class TagsController < ApplicationController
include SignatureVerification
include WebAppControllerConcern
PAGE_SIZE = 20
PAGE_SIZE_MAX = 200
layout 'public'
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_local
before_action :set_tag
before_action :set_statuses
before_action :set_body_classes
before_action :set_instance_presenter
skip_before_action :require_functional!, unless: :whitelist_mode?
@ -21,7 +19,7 @@ class TagsController < ApplicationController
def show
respond_to do |format|
format.html do
redirect_to web_path("tags/#{@tag.name}")
expires_in 0, public: true unless user_signed_in?
end
format.rss do
@ -54,10 +52,6 @@ class TagsController < ApplicationController
end
end
def set_body_classes
@body_classes = 'with-modals'
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end

@ -20,54 +20,10 @@ module AccountsHelper
end
def account_action_button(account)
if user_signed_in?
if account.id == current_user.account_id
link_to settings_profile_url, class: 'button logo-button' do
safe_join([logo_as_symbol, t('settings.edit_profile')])
end
elsif current_account.following?(account) || current_account.requested?(account)
link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do
safe_join([logo_as_symbol, t('accounts.unfollow')])
end
elsif !(account.memorial? || account.moved?)
link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do
safe_join([logo_as_symbol, t('accounts.follow')])
end
end
elsif !(account.memorial? || account.moved?)
link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do
safe_join([logo_as_symbol, t('accounts.follow')])
end
end
end
def minimal_account_action_button(account)
if user_signed_in?
return if account.id == current_user.account_id
if current_account.following?(account) || current_account.requested?(account)
link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
fa_icon('user-times fw')
end
elsif !(account.memorial? || account.moved?)
link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
fa_icon('user-plus fw')
end
end
elsif !(account.memorial? || account.moved?)
link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
fa_icon('user-plus fw')
end
end
end
return if account.memorial? || account.moved?
def account_badge(account)
if account.bot?
content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
elsif account.group?
content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles')
elsif account.user_role&.highlighted?
content_tag(:div, content_tag(:div, account.user_role.name, class: "account-role user-role-#{account.user_role.id}"), class: 'roles')
link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
safe_join([logo_as_symbol, t('accounts.follow')])
end
end

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { version, source_url } from 'mastodon/initial_state';
import StackTrace from 'stacktrace-js';
import { Helmet } from 'react-helmet';
export default class ErrorBoundary extends React.PureComponent {
@ -84,6 +85,7 @@ export default class ErrorBoundary extends React.PureComponent {
<FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' />
)}
</p>
<p>
{ likelyBrowserAddonIssue ? (
<FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
@ -91,8 +93,13 @@ export default class ErrorBoundary extends React.PureComponent {
<FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
)}
</p>
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</div>
);
}

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import illustration from 'mastodon/../images/elephant_ui_disappointed.svg';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
const MissingIndicator = ({ fullPage }) => (
<div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}>
@ -14,6 +15,10 @@ const MissingIndicator = ({ fullPage }) => (
<FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
<FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</div>
);

@ -78,7 +78,7 @@ export default class Mastodon extends React.PureComponent {
<IntlProvider locale={locale} messages={messages}>
<ReduxProvider store={store}>
<ErrorBoundary>
<BrowserRouter basename='/web'>
<BrowserRouter>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} />
</ScrollContext>

@ -94,6 +94,7 @@ class About extends React.PureComponent {
}),
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
componentDidMount () {
@ -108,11 +109,11 @@ class About extends React.PureComponent {
}
render () {
const { intl, server, extendedDescription, domainBlocks } = this.props;
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
const isLoading = server.get('isLoading');
return (
<Column>
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<div className='scrollable about'>
<div className='about__header'>
<Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
@ -212,6 +213,7 @@ class About extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);

@ -270,7 +270,9 @@ class Header extends ImmutablePureComponent {
const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields');
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
const isLocal = account.get('acct').indexOf('@') === -1;
const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
const isIndexable = !account.get('noindex');
let badge;
@ -373,6 +375,7 @@ class Header extends ImmutablePureComponent {
<Helmet>
<title>{titleFromAccount(account)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
</Helmet>
</div>
);

@ -142,19 +142,17 @@ class AccountTimeline extends ImmutablePureComponent {
render () {
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) {
if (isLoading && statusIds.isEmpty()) {
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<MissingIndicator />
<LoadingIndicator />
</Column>
);
}
if (!statusIds && isLoading) {
} else if (!isLoading && !isAccount) {
return (
<Column>
<LoadingIndicator />
<ColumnBackButton multiColumn={multiColumn} />
<MissingIndicator />
</Column>
);
}

@ -99,6 +99,7 @@ class Bookmarks extends ImmutablePureComponent {
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -151,6 +151,7 @@ class CommunityTimeline extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -18,6 +18,7 @@ import { mascot } from '../../initial_state';
import Icon from 'mastodon/components/icon';
import { logOut } from 'mastodon/utils/log_out';
import Column from 'mastodon/components/column';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -145,6 +146,10 @@ class Compose extends React.PureComponent {
<Column onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer />
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -98,6 +98,7 @@ class DirectTimeline extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -169,6 +169,7 @@ class Directory extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -11,6 +11,7 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import DomainContainer from '../../containers/domain_container';
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
import ScrollableList from '../../components/scrollable_list';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
@ -59,6 +60,7 @@ class Blocks extends ImmutablePureComponent {
return (
<Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList
scrollKey='domain_blocks'
onLoadMore={this.handleLoadMore}
@ -70,6 +72,10 @@ class Blocks extends ImmutablePureComponent {
<DomainContainer key={domain} domain={domain} />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -84,6 +84,7 @@ class Explore extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
</Helmet>
</React.Fragment>
)}

@ -99,6 +99,7 @@ class Favourites extends ImmutablePureComponent {
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -11,6 +11,7 @@ import LoadingIndicator from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import AccountContainer from 'mastodon/containers/account_container';
import Column from 'mastodon/features/ui/components/column';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
@ -80,6 +81,10 @@ class Favourites extends ImmutablePureComponent {
<AccountContainer key={id} id={id} withNote={false} />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -12,6 +12,7 @@ import Column from 'mastodon/features/ui/components/column';
import Account from './components/account';
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
import Button from 'mastodon/components/button';
import { Helmet } from 'react-helmet';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
@ -104,6 +105,10 @@ class FollowRecommendations extends ImmutablePureComponent {
</React.Fragment>
)}
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -12,6 +12,7 @@ import AccountAuthorizeContainer from './containers/account_authorize_container'
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
import ScrollableList from '../../components/scrollable_list';
import { me } from '../../initial_state';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
@ -87,6 +88,10 @@ class FollowRequests extends ImmutablePureComponent {
<AccountAuthorizeContainer key={id} id={id} />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -138,6 +138,7 @@ class GettingStarted extends ImmutablePureComponent {
<Helmet>
<title>{intl.formatMessage(messages.menu)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -228,6 +228,7 @@ class HashtagTimeline extends React.PureComponent {
<Helmet>
<title>#{id}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -167,6 +167,7 @@ class HomeTimeline extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -4,6 +4,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ColumnHeader from 'mastodon/components/column_header';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
@ -164,6 +165,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
</tbody>
</table>
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -212,6 +212,7 @@ class ListTimeline extends React.PureComponent {
<Helmet>
<title>{title}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -80,6 +80,7 @@ class Lists extends ImmutablePureComponent {
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -11,6 +11,7 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import AccountContainer from '../../containers/account_container';
import { fetchMutes, expandMutes } from '../../actions/mutes';
import ScrollableList from '../../components/scrollable_list';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
@ -72,6 +73,10 @@ class Mutes extends ImmutablePureComponent {
<AccountContainer key={id} id={id} defaultAction='mute' />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -281,6 +281,7 @@ class Notifications extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -8,6 +8,7 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import StatusList from '../../components/status_list';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
@ -54,6 +55,9 @@ class PinnedStatuses extends ImmutablePureComponent {
hasMore={hasMore}
bindToDocument={!multiColumn}
/>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -15,6 +15,7 @@ class PrivacyPolicy extends React.PureComponent {
static propTypes = {
intl: PropTypes.object,
multiColumn: PropTypes.bool,
};
state = {
@ -32,11 +33,11 @@ class PrivacyPolicy extends React.PureComponent {
}
render () {
const { intl } = this.props;
const { intl, multiColumn } = this.props;
const { isLoading, content, lastUpdated } = this.state;
return (
<Column>
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3><FormattedMessage id='privacy_policy.title' defaultMessage='Privacy Policy' /></h3>
@ -51,6 +52,7 @@ class PrivacyPolicy extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);

@ -153,6 +153,7 @@ class PublicTimeline extends React.PureComponent {
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);

@ -11,6 +11,7 @@ import Column from '../ui/components/column';
import ScrollableList from '../../components/scrollable_list';
import Icon from 'mastodon/components/icon';
import ColumnHeader from '../../components/column_header';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
@ -80,6 +81,10 @@ class Reblogs extends ImmutablePureComponent {
<AccountContainer key={id} id={id} withNote={false} />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
@ -145,6 +146,7 @@ const makeMapStateToProps = () => {
}
return {
isLoading: state.getIn(['statuses', props.params.statusId, 'isLoading']),
status,
ancestorsIds,
descendantsIds,
@ -187,6 +189,7 @@ class Status extends ImmutablePureComponent {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
isLoading: PropTypes.bool,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
@ -566,9 +569,17 @@ class Status extends ImmutablePureComponent {
render () {
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
const { isLoading, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
const { fullscreen } = this.state;
if (isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
if (status === null) {
return (
<Column>
@ -586,6 +597,9 @@ class Status extends ImmutablePureComponent {
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
}
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
const isIndexable = !status.getIn(['account', 'noindex']);
const handlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
@ -659,6 +673,7 @@ class Status extends ImmutablePureComponent {
<Helmet>
<title>{titleFromStatus(status)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
</Helmet>
</Column>
);

@ -1,11 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import Column from './column';
import ColumnHeader from './column_header';
import ColumnBackButtonSlim from '../../../components/column_back_button_slim';
import IconButton from '../../../components/icon_button';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import IconButton from 'mastodon/components/icon_button';
import { Helmet } from 'react-helmet';
const messages = defineMessages({
title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
@ -18,6 +17,7 @@ class BundleColumnError extends React.PureComponent {
static propTypes = {
onRetry: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
}
handleRetry = () => {
@ -25,16 +25,25 @@ class BundleColumnError extends React.PureComponent {
}
render () {
const { intl: { formatMessage } } = this.props;
const { multiColumn, intl: { formatMessage } } = this.props;
return (
<Column>
<ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} />
<ColumnBackButtonSlim />
<Column bindToDocument={!multiColumn} label={formatMessage(messages.title)}>
<ColumnHeader
icon='exclamation-circle'
title={formatMessage(messages.title)}
showBackButton
multiColumn={multiColumn}
/>
<div className='error-column'>
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
{formatMessage(messages.body)}
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}

@ -10,6 +10,7 @@ export default class ColumnLoading extends ImmutablePureComponent {
static propTypes = {
title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
icon: PropTypes.string,
multiColumn: PropTypes.bool,
};
static defaultProps = {
@ -18,10 +19,11 @@ export default class ColumnLoading extends ImmutablePureComponent {
};
render() {
let { title, icon } = this.props;
let { title, icon, multiColumn } = this.props;
return (
<Column>
<ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} placeholder />
<ColumnHeader icon={icon} title={title} multiColumn={multiColumn} focusable={false} placeholder />
<div className='scrollable' />
</Column>
);

@ -139,11 +139,11 @@ class ColumnsArea extends ImmutablePureComponent {
}
renderLoading = columnId => () => {
return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading multiColumn />;
}
renderError = (props) => {
return <BundleColumnError {...props} />;
return <BundleColumnError multiColumn {...props} />;
}
render () {

@ -11,9 +11,7 @@ import VideoModal from './video_modal';
import BoostModal from './boost_modal';
import AudioModal from './audio_modal';
import ConfirmationModal from './confirmation_modal';
import SubscribedLanguagesModal from 'mastodon/features/subscribed_languages_modal';
import FocalPointModal from './focal_point_modal';
import InteractionModal from 'mastodon/features/interaction_modal';
import {
MuteModal,
BlockModal,
@ -23,7 +21,10 @@ import {
ListAdder,
CompareHistoryModal,
FilterModal,
InteractionModal,
SubscribedLanguagesModal,
} from 'mastodon/features/ui/util/async-components';
import { Helmet } from 'react-helmet';
const MODAL_COMPONENTS = {
'MEDIA': () => Promise.resolve({ default: MediaModal }),
@ -41,8 +42,8 @@ const MODAL_COMPONENTS = {
'LIST_ADDER': ListAdder,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
'SUBSCRIBED_LANGUAGES': () => Promise.resolve({ default: SubscribedLanguagesModal }),
'INTERACTION': () => Promise.resolve({ default: InteractionModal }),
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,
'INTERACTION': InteractionModal,
};
export default class ModalRoot extends React.PureComponent {
@ -111,9 +112,15 @@ export default class ModalRoot extends React.PureComponent {
return (
<Base backgroundColor={backgroundColor} onClose={this.handleClose} ignoreFocus={ignoreFocus}>
{visible && (
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
</BundleContainer>
<>
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
</BundleContainer>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</>
)}
</Base>
);

@ -197,8 +197,8 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
<WrappedRoute path={['/@:acct/followers', '/accounts/:id/followers']} component={Followers} content={children} />
<WrappedRoute path={['/@:acct/following', '/accounts/:id/following']} component={Following} content={children} />
<WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
<WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} />
<WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />

@ -166,6 +166,14 @@ export function FilterModal () {
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
}
export function InteractionModal () {
return import(/*webpackChunkName: "modals/interaction_modal" */'../../interaction_modal');
}
export function SubscribedLanguagesModal () {
return import(/*webpackChunkName: "modals/subscribed_languages_modal" */'../../subscribed_languages_modal');
}
export function About () {
return import(/*webpackChunkName: "features/about" */'../../about');
}

@ -53,7 +53,9 @@ export class WrappedRoute extends React.Component {
}
renderLoading = () => {
return <ColumnLoading />;
const { multiColumn } = this.props;
return <ColumnLoading multiColumn={multiColumn} />;
}
renderError = (props) => {

@ -12,14 +12,6 @@ const perf = require('mastodon/performance');
function main() {
perf.start('main()');
if (window.history && history.replaceState) {
const { pathname, search, hash } = window.location;
const path = pathname + search + hash;
if (!(/^\/web($|\/)/).test(path)) {
history.replaceState(null, document.title, `/web${path}`);
}
}
return ready(async () => {
const mountNode = document.getElementById('mastodon');
const props = JSON.parse(mountNode.getAttribute('data-props'));

@ -15,6 +15,8 @@ import {
STATUS_COLLAPSE,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO,
STATUS_FETCH_REQUEST,
STATUS_FETCH_FAIL,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
@ -37,6 +39,10 @@ const initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
switch(action.type) {
case STATUS_FETCH_REQUEST:
return state.setIn([action.id, 'isLoading'], true);
case STATUS_FETCH_FAIL:
return state.delete(action.id);
case STATUS_IMPORT:
return importStatus(state, action.status);
case STATUSES_IMPORT:

@ -41,7 +41,7 @@ export const makeGetStatus = () => {
],
(statusBase, statusReblog, accountBase, accountReblog, filters) => {
if (!statusBase) {
if (!statusBase || statusBase.get('isLoading')) {
return null;
}

@ -15,7 +15,7 @@ const notify = options =>
icon: '/android-chrome-192x192.png',
tag: GROUP_TAG,
data: {
url: (new URL('/web/notifications', self.location)).href,
url: (new URL('/notifications', self.location)).href,
count: notifications.length + 1,
preferred_locale: options.data.preferred_locale,
},
@ -90,7 +90,7 @@ export const handlePush = (event) => {
options.tag = notification.id;
options.badge = '/badge.png';
options.image = notification.status && notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url || undefined;
options.data = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id, url: notification.status ? `/web/@${notification.account.acct}/${notification.status.id}` : `/web/@${notification.account.acct}` };
options.data = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id, url: notification.status ? `/@${notification.account.acct}/${notification.status.id}` : `/@${notification.account.acct}` };
if (notification.status && notification.status.spoiler_text || notification.status.sensitive) {
options.data.hiddenBody = htmlToPlainText(notification.status.content);
@ -115,7 +115,7 @@ export const handlePush = (event) => {
tag: notification_id,
timestamp: new Date(),
badge: '/badge.png',
data: { access_token, preferred_locale, url: '/web/notifications' },
data: { access_token, preferred_locale, url: '/notifications' },
});
}),
);
@ -166,24 +166,10 @@ const removeActionFromNotification = (notification, action) => {
const openUrl = url =>
self.clients.matchAll({ type: 'window' }).then(clientList => {
if (clientList.length !== 0) {
const webClients = clientList.filter(client => /\/web\//.test(client.url));
if (webClients.length !== 0) {
const client = findBestClient(webClients);
const { pathname } = new URL(url, self.location);
if (pathname.startsWith('/web/')) {
return client.focus().then(client => client.postMessage({
type: 'navigate',
path: pathname.slice('/web/'.length - 1),
}));
}
} else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
const client = findBestClient(clientList);
if (clientList.length !== 0 && 'navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
const client = findBestClient(clientList);
return client.navigate(url).then(client => client.focus());
}
return client.navigate(url).then(client => client.focus());
}
return self.clients.openWindow(url);

@ -33,7 +33,6 @@ function main() {
const { messages } = getLocale();
const React = require('react');
const ReactDOM = require('react-dom');
const Rellax = require('rellax');
const { createBrowserHistory } = require('history');
const scrollToDetailedStatus = () => {
@ -112,12 +111,6 @@ function main() {
scrollToDetailedStatus();
}
const parallaxComponents = document.querySelectorAll('.parallax');
if (parallaxComponents.length > 0 ) {
new Rellax('.parallax', { speed: -1 });
}
delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
const password = document.getElementById('registration_user_password');
const confirmation = document.getElementById('registration_user_password_confirmation');
@ -168,28 +161,6 @@ function main() {
});
});
delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
if (button !== 0) {
return true;
}
window.location.href = target.href;
return false;
});
delegate(document, '.modal-button', 'click', e => {
e.preventDefault();
let href;
if (e.target.nodeName !== 'A') {
href = e.target.parentNode.href;
} else {
href = e.target.href;
}
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
});
delegate(document, '#account_display_name', 'input', ({ target }) => {
const name = document.querySelector('.card .display-name strong');
if (name) {

@ -8,7 +8,6 @@
@import 'mastodon/branding';
@import 'mastodon/containers';
@import 'mastodon/lists';
@import 'mastodon/footer';
@import 'mastodon/widgets';
@import 'mastodon/forms';
@import 'mastodon/accounts';

@ -68,10 +68,6 @@
color: $darker-text-color;
}
.public-layout .public-account-header__tabs__tabs .counter.active::after {
border-bottom: 4px solid $ui-highlight-color;
}
.compose-form .autosuggest-textarea__textarea::placeholder,
.compose-form .spoiler-input__input::placeholder {
color: $inverted-text-color;

@ -655,95 +655,6 @@ html {
}
}
.public-layout {
.account__section-headline {
border: 1px solid lighten($ui-base-color, 8%);
@media screen and (max-width: $no-gap-breakpoint) {
border-top: 0;
}
}
.header,
.public-account-header,
.public-account-bio {
box-shadow: none;
}
.public-account-bio,
.hero-widget__text {
background: $account-background-color;
}
.header {
background: $ui-base-color;
border: 1px solid lighten($ui-base-color, 8%);
@media screen and (max-width: $no-gap-breakpoint) {
border: 0;
}
.brand {
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 4%);
}
}
}
.public-account-header {
&__image {
background: lighten($ui-base-color, 12%);
&::after {
box-shadow: none;
}
}
&__bar {
&::before {
background: $account-background-color;
border: 1px solid lighten($ui-base-color, 8%);
border-top: 0;
}
.avatar img {
border-color: $account-background-color;
}
@media screen and (max-width: $no-columns-breakpoint) {
background: $account-background-color;
border: 1px solid lighten($ui-base-color, 8%);
border-top: 0;
}
}
&__tabs {
&__name {
h1,
h1 small {
color: $white;
@media screen and (max-width: $no-columns-breakpoint) {
color: $primary-text-color;
}
}
}
}
&__extra {
.public-account-bio {
border: 0;
}
.public-account-bio .account__header__fields {
border-color: lighten($ui-base-color, 8%);
}
}
}
}
.notification__filter-bar button.active::after,
.account__section-headline a.active::after {
border-color: transparent transparent $white;

@ -104,785 +104,3 @@
margin-left: 10px;
}
}
.grid-3 {
display: grid;
grid-gap: 10px;
grid-template-columns: 3fr 1fr;
grid-auto-columns: 25%;
grid-auto-rows: max-content;
.column-0 {
grid-column: 1 / 3;
grid-row: 1;
}
.column-1 {
grid-column: 1;
grid-row: 2;
}
.column-2 {
grid-column: 2;
grid-row: 2;
}
.column-3 {
grid-column: 1 / 3;
grid-row: 3;
}
@media screen and (max-width: $no-gap-breakpoint) {
grid-gap: 0;
grid-template-columns: minmax(0, 100%);
.column-0 {
grid-column: 1;
}
.column-1 {
grid-column: 1;
grid-row: 3;
}
.column-2 {
grid-column: 1;
grid-row: 2;
}
.column-3 {
grid-column: 1;
grid-row: 4;
}
}
}
.grid-4 {
display: grid;
grid-gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-columns: 25%;
grid-auto-rows: max-content;
.column-0 {
grid-column: 1 / 5;
grid-row: 1;
}
.column-1 {
grid-column: 1 / 4;
grid-row: 2;
}
.column-2 {
grid-column: 4;
grid-row: 2;
}
.column-3 {
grid-column: 2 / 5;
grid-row: 3;
}
.column-4 {
grid-column: 1;
grid-row: 3;
}
.landing-page__call-to-action {
min-height: 100%;
}
.flash-message {
margin-bottom: 10px;
}
@media screen and (max-width: 738px) {
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
.landing-page__call-to-action {
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.row__information-board {
width: 100%;
justify-content: center;
align-items: center;
}
.row__mascot {
display: none;
}
}
@media screen and (max-width: $no-gap-breakpoint) {
grid-gap: 0;
grid-template-columns: minmax(0, 100%);
.column-0 {
grid-column: 1;
}
.column-1 {
grid-column: 1;
grid-row: 3;
}
.column-2 {
grid-column: 1;
grid-row: 2;
}
.column-3 {
grid-column: 1;
grid-row: 5;
}
.column-4 {
grid-column: 1;
grid-row: 4;
}
}
}
.public-layout {
@media screen and (max-width: $no-gap-breakpoint) {
padding-top: 48px;
}
.container {
max-width: 960px;
@media screen and (max-width: $no-gap-breakpoint) {
padding: 0;
}
}
.header {
background: lighten($ui-base-color, 8%);
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
border-radius: 4px;
height: 48px;
margin: 10px 0;
display: flex;
align-items: stretch;
justify-content: center;
flex-wrap: nowrap;
overflow: hidden;
@media screen and (max-width: $no-gap-breakpoint) {
position: fixed;
width: 100%;
top: 0;
left: 0;
margin: 0;
border-radius: 0;
box-shadow: none;
z-index: 110;
}
& > div {
flex: 1 1 33.3%;
min-height: 1px;
}
.nav-left {
display: flex;
align-items: stretch;
justify-content: flex-start;
flex-wrap: nowrap;
}
.nav-center {
display: flex;
align-items: stretch;
justify-content: center;
flex-wrap: nowrap;
}
.nav-right {
display: flex;
align-items: stretch;
justify-content: flex-end;
flex-wrap: nowrap;
}
.brand {
display: block;
padding: 15px;
.logo {
display: block;
height: 18px;
width: auto;
position: relative;
bottom: -2px;
fill: $primary-text-color;
@media screen and (max-width: $no-gap-breakpoint) {
height: 20px;
}
}
&:hover,
&:focus,
&:active {
background: lighten($ui-base-color, 12%);
}
}
.nav-link {
display: flex;
align-items: center;
padding: 0 1rem;
font-size: 12px;
font-weight: 500;
text-decoration: none;
color: $darker-text-color;
white-space: nowrap;
text-align: center;
&:hover,
&:focus,
&:active {
text-decoration: underline;
color: $primary-text-color;
}
@media screen and (max-width: 550px) {
&.optional {
display: none;
}
}
}
.nav-button {
background: lighten($ui-base-color, 16%);
margin: 8px;
margin-left: 0;
border-radius: 4px;
&:hover,
&:focus,
&:active {
text-decoration: none;
background: lighten($ui-base-color, 20%);
}
}
}
$no-columns-breakpoint: 600px;
.grid {
display: grid;
grid-gap: 10px;
grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);
grid-auto-columns: 25%;
grid-auto-rows: max-content;
.column-0 {
grid-row: 1;
grid-column: 1;
}
.column-1 {
grid-row: 1;
grid-column: 2;
}
@media screen and (max-width: $no-columns-breakpoint) {
grid-template-columns: 100%;
grid-gap: 0;
.column-1 {
display: none;
}
}
}
.page-header {
@media screen and (max-width: $no-gap-breakpoint) {
border-bottom: 0;
}
}
.public-account-header {
overflow: hidden;
margin-bottom: 10px;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
&.inactive {
opacity: 0.5;
.public-account-header__image,
.avatar {
filter: grayscale(100%);
}
.logo-button {
background-color: $secondary-text-color;
}
}
.logo-button {
padding: 3px 15px;
}
&__image {
border-radius: 4px 4px 0 0;
overflow: hidden;
height: 300px;
position: relative;
background: darken($ui-base-color, 12%);
&::after {
content: "";
display: block;
position: absolute;
width: 100%;
height: 100%;
box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);
top: 0;
left: 0;
}
img {
object-fit: cover;
display: block;
width: 100%;
height: 100%;
margin: 0;
border-radius: 4px 4px 0 0;
}
@media screen and (max-width: 600px) {
height: 200px;
}
}
&--no-bar {
margin-bottom: 0;
.public-account-header__image,
.public-account-header__image img {
border-radius: 4px;
@media screen and (max-width: $no-gap-breakpoint) {
border-radius: 0;
}
}
}
@media screen and (max-width: $no-gap-breakpoint) {
margin-bottom: 0;
box-shadow: none;
&__image::after {
display: none;
}
&__image,
&__image img {
border-radius: 0;
}
}
&__bar {
position: relative;
margin-top: -80px;
display: flex;
justify-content: flex-start;
&::before {
content: "";
display: block;
background: lighten($ui-base-color, 4%);
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
border-radius: 0 0 4px 4px;
z-index: -1;
}
.avatar {
display: block;
width: 120px;
height: 120px;
padding-left: 20px - 4px;
flex: 0 0 auto;
img {
display: block;
width: 100%;
height: 100%;
margin: 0;
border-radius: 50%;
border: 4px solid lighten($ui-base-color, 4%);
background: darken($ui-base-color, 8%);
}
}
@media screen and (max-width: 600px) {
margin-top: 0;
background: lighten($ui-base-color, 4%);
border-radius: 0 0 4px 4px;
padding: 5px;
&::before {
display: none;
}
.avatar {
width: 48px;
height: 48px;
padding: 7px 0;
padding-left: 10px;
img {
border: 0;
border-radius: 4px;
}
@media screen and (max-width: 360px) {
display: none;
}
}
}
@media screen and (max-width: $no-gap-breakpoint) {
border-radius: 0;
}
@media screen and (max-width: $no-columns-breakpoint) {
flex-wrap: wrap;
}
}
&__tabs {
flex: 1 1 auto;
margin-left: 20px;
&__name {
padding-top: 20px;
padding-bottom: 8px;
h1 {
font-size: 20px;
line-height: 18px * 1.5;
color: $primary-text-color;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-shadow: 1px 1px 1px $base-shadow-color;
small {
display: block;
font-size: 14px;
color: $primary-text-color;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@media screen and (max-width: 600px) {
margin-left: 15px;
display: flex;
justify-content: space-between;
align-items: center;
&__name {
padding-top: 0;
padding-bottom: 0;
h1 {
font-size: 16px;
line-height: 24px;
text-shadow: none;
small {
color: $darker-text-color;
}
}
}
}
&__tabs {
display: flex;
justify-content: flex-start;
align-items: stretch;
height: 58px;
.details-counters {
display: flex;
flex-direction: row;
min-width: 300px;
}
@media screen and (max-width: $no-columns-breakpoint) {
.details-counters {
display: none;
}
}
.counter {
min-width: 33.3%;
box-sizing: border-box;
flex: 0 0 auto;
color: $darker-text-color;
padding: 10px;
border-right: 1px solid lighten($ui-base-color, 4%);
cursor: default;
text-align: center;
position: relative;
a {
display: block;
}
&:last-child {
border-right: 0;
}
&::after {
display: block;
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-bottom: 4px solid $ui-primary-color;
opacity: 0.5;
transition: all 400ms ease;
}
&.active {
&::after {
border-bottom: 4px solid $highlight-text-color;
opacity: 1;
}
&.inactive::after {
border-bottom-color: $secondary-text-color;
}
}
&:hover {
&::after {
opacity: 1;
transition-duration: 100ms;
}
}
a {
text-decoration: none;
color: inherit;
}
.counter-label {
font-size: 12px;
display: block;
}
.counter-number {
font-weight: 500;
font-size: 18px;
margin-bottom: 5px;
color: $primary-text-color;
font-family: $font-display, sans-serif;
}
}
.spacer {
flex: 1 1 auto;
height: 1px;
}
&__buttons {
padding: 7px 8px;
}
}
}
&__extra {
display: none;
margin-top: 4px;
.public-account-bio {
border-radius: 0;
box-shadow: none;
background: transparent;
margin: 0 -5px;
.account__header__fields {
border-top: 1px solid lighten($ui-base-color, 12%);
}
.roles {
display: none;
}
}
&__links {
margin-top: -15px;
font-size: 14px;
color: $darker-text-color;
a {
display: inline-block;
color: $darker-text-color;
text-decoration: none;
padding: 15px;
font-weight: 500;
strong {
font-weight: 700;
color: $primary-text-color;
}
}
}
@media screen and (max-width: $no-columns-breakpoint) {
display: block;
flex: 100%;
}
}
}
.account__section-headline {
border-radius: 4px 4px 0 0;
@media screen and (max-width: $no-gap-breakpoint) {
border-radius: 0;
}
}
.detailed-status__meta {
margin-top: 25px;
}
.public-account-bio {
background: lighten($ui-base-color, 8%);
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
@media screen and (max-width: $no-gap-breakpoint) {
box-shadow: none;
margin-bottom: 0;
border-radius: 0;
}
.account__header__fields {
margin: 0;
border-top: 0;
a {
color: $highlight-text-color;
}
dl:first-child .verified {
border-radius: 0 4px 0 0;
}
.verified a {
color: $valid-value-color;
}
}
.account__header__content {
padding: 20px;
padding-bottom: 0;
color: $primary-text-color;
}
&__extra,
.roles {
padding: 20px;
font-size: 14px;
color: $darker-text-color;
}
.roles {
padding-bottom: 0;
}
}
.directory__list {
display: grid;
grid-gap: 10px;
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
.account-card {
display: flex;
flex-direction: column;
}
@media screen and (max-width: $no-gap-breakpoint) {
display: block;
.account-card {
margin-bottom: 10px;
display: block;
}
}
}
.card-grid {
display: flex;
flex-wrap: wrap;
min-width: 100%;
margin: 0 -5px;
& > div {
box-sizing: border-box;
flex: 1 0 auto;
width: 300px;
padding: 0 5px;
margin-bottom: 10px;
max-width: 33.333%;
@media screen and (max-width: 900px) {
max-width: 50%;
}
@media screen and (max-width: 600px) {
max-width: 100%;
}
}
@media screen and (max-width: $no-gap-breakpoint) {
margin: 0;
border-top: 1px solid lighten($ui-base-color, 8%);
& > div {
width: 100%;
padding: 0;
margin-bottom: 0;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&:last-child {
border-bottom: 0;
}
.card__bar {
background: $ui-base-color;
&:hover,
&:active,
&:focus {
background: lighten($ui-base-color, 4%);
}
}
}
}
}
}

@ -1,152 +0,0 @@
.public-layout {
.footer {
text-align: left;
padding-top: 20px;
padding-bottom: 60px;
font-size: 12px;
color: lighten($ui-base-color, 34%);
@media screen and (max-width: $no-gap-breakpoint) {
padding-left: 20px;
padding-right: 20px;
}
.grid {
display: grid;
grid-gap: 10px;
grid-template-columns: 1fr 1fr 2fr 1fr 1fr;
.column-0 {
grid-column: 1;
grid-row: 1;
min-width: 0;
}
.column-1 {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.column-2 {
grid-column: 3;
grid-row: 1;
min-width: 0;
text-align: center;
h4 a {
color: lighten($ui-base-color, 34%);
}
}
.column-3 {
grid-column: 4;
grid-row: 1;
min-width: 0;
}
.column-4 {
grid-column: 5;
grid-row: 1;
min-width: 0;
}
@media screen and (max-width: 690px) {
grid-template-columns: 1fr 2fr 1fr;
.column-0,
.column-1 {
grid-column: 1;
}
.column-1 {
grid-row: 2;
}
.column-2 {
grid-column: 2;
}
.column-3,
.column-4 {
grid-column: 3;
}
.column-4 {
grid-row: 2;
}
}
@media screen and (max-width: 600px) {
.column-1 {
display: block;
}
}
@media screen and (max-width: $no-gap-breakpoint) {
.column-0,
.column-1,
.column-3,
.column-4 {
display: none;
}
.column-2 h4 {
display: none;
}
}
}
.legal-xs {
display: none;
text-align: center;
padding-top: 20px;
@media screen and (max-width: $no-gap-breakpoint) {
display: block;
}
}
h4 {
text-transform: uppercase;
font-weight: 700;
margin-bottom: 8px;
color: $darker-text-color;
a {
color: inherit;
text-decoration: none;
}
}
ul a,
.legal-xs a {
text-decoration: none;
color: lighten($ui-base-color, 34%);
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
}
.brand {
.logo {
display: block;
height: 36px;
width: auto;
margin: 0 auto;
color: lighten($ui-base-color, 34%);
}
&:hover,
&:focus,
&:active {
.logo {
color: lighten($ui-base-color, 38%);
}
}
}
}
}

@ -53,16 +53,6 @@ body.rtl {
right: -26px;
}
.landing-page__logo {
margin-right: 0;
margin-left: 20px;
}
.landing-page .features-list .features-list__row .visual {
margin-left: 0;
margin-right: 15px;
}
.column-link__icon,
.column-header__icon {
margin-right: 0;
@ -350,44 +340,6 @@ body.rtl {
margin-left: 45px;
}
.landing-page .header-wrapper .mascot {
right: 60px;
left: auto;
}
.landing-page__call-to-action .row__information-board {
direction: rtl;
}
.landing-page .header .hero .floats .float-1 {
left: -120px;
right: auto;
}
.landing-page .header .hero .floats .float-2 {
left: 210px;
right: auto;
}
.landing-page .header .hero .floats .float-3 {
left: 110px;
right: auto;
}
.landing-page .header .links .brand img {
left: 0;
}
.landing-page .fa-external-link {
padding-right: 5px;
padding-left: 0 !important;
}
.landing-page .features #mastodon-timeline {
margin-right: 0;
margin-left: 30px;
}
@media screen and (min-width: 631px) {
.column,
.drawer {
@ -415,32 +367,6 @@ body.rtl {
padding-right: 0;
}
.public-layout {
.header {
.nav-button {
margin-left: 8px;
margin-right: 0;
}
}
.public-account-header__tabs {
margin-left: 0;
margin-right: 20px;
}
}
.landing-page__information {
.account__display-name {
margin-right: 0;
margin-left: 5px;
}
.account__avatar-wrapper {
margin-left: 12px;
margin-right: 0;
}
}
.card__bar .display-name {
margin-left: 0;
margin-right: 15px;

@ -137,8 +137,7 @@ a.button.logo-button {
justify-content: center;
}
.embed,
.public-layout {
.embed {
.status__content[data-spoiler="folded"] {
.e-content {
display: none;

@ -8,16 +8,14 @@ class PermalinkRedirector
end
def redirect_path
if path_segments[0] == 'web'
if path_segments[1].present? && path_segments[1].start_with?('@') && path_segments[2] =~ /\d/
find_status_url_by_id(path_segments[2])
elsif path_segments[1].present? && path_segments[1].start_with?('@')
find_account_url_by_name(path_segments[1])
elsif path_segments[1] == 'statuses' && path_segments[2] =~ /\d/
find_status_url_by_id(path_segments[2])
elsif path_segments[1] == 'accounts' && path_segments[2] =~ /\d/
find_account_url_by_id(path_segments[2])
end
if path_segments[0].present? && path_segments[0].start_with?('@') && path_segments[1] =~ /\d/
find_status_url_by_id(path_segments[1])
elsif path_segments[0].present? && path_segments[0].start_with?('@')
find_account_url_by_name(path_segments[0])
elsif path_segments[0] == 'statuses' && path_segments[1] =~ /\d/
find_status_url_by_id(path_segments[1])
elsif path_segments[0] == 'accounts' && path_segments[1] =~ /\d/
find_account_url_by_id(path_segments[1])
end
end
@ -29,18 +27,12 @@ class PermalinkRedirector
def find_status_url_by_id(id)
status = Status.find_by(id: id)
return unless status&.distributable?
ActivityPub::TagManager.instance.url_for(status)
ActivityPub::TagManager.instance.url_for(status) if status&.distributable? && !status.account.local?
end
def find_account_url_by_id(id)
account = Account.find_by(id: id)
return unless account
ActivityPub::TagManager.instance.url_for(account)
ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
end
def find_account_url_by_name(name)
@ -48,12 +40,6 @@ class PermalinkRedirector
domain = nil if TagManager.instance.local_domain?(domain)
account = Account.find_remote(username, domain)
return unless account
ActivityPub::TagManager.instance.url_for(account)
end
def find_tag_url_by_name(name)
tag_path(CGI.unescape(name))
ActivityPub::TagManager.instance.url_for(account) if account.present? && !account.local?
end
end

@ -134,6 +134,7 @@ class Account < ApplicationRecord
:role,
:locale,
:shows_application?,
:prefers_noindex?,
to: :user,
prefix: true,
allow_nil: true

@ -281,6 +281,10 @@ class User < ApplicationRecord
save!
end
def prefers_noindex?
setting_noindex
end
def preferred_posting_language
valid_locale_cascade(settings.default_language, locale, I18n.locale)
end

@ -14,6 +14,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
attribute :suspended, if: :suspended?
attribute :silenced, key: :limited, if: :silenced?
attribute :noindex, if: :local?
class FieldSerializer < ActiveModel::Serializer
include FormattingHelper
@ -103,7 +104,11 @@ class REST::AccountSerializer < ActiveModel::Serializer
object.silenced?
end
delegate :suspended?, :silenced?, to: :object
def noindex
object.user_prefers_noindex?
end
delegate :suspended?, :silenced?, :local?, to: :object
def moved_and_not_nested?
object.moved? && object.moved_to_account.moved_to_account_id.nil?

@ -1,4 +1,7 @@
- content_for :page_title do
= t('about.title')
- content_for :header_tags do
= render partial: 'shared/og'
= render partial: 'shared/web_app'

@ -1,21 +0,0 @@
- fields = account.fields
.public-account-bio
- unless fields.empty?
.account__header__fields
- fields.each do |field|
%dl
%dt.emojify{ title: field.name }= prerender_custom_emojis(h(field.name), account.emojis)
%dd{ title: field.value, class: custom_field_classes(field) }
- if field.verified?
%span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) }
= fa_icon 'check'
= prerender_custom_emojis(account_field_value_format(field), account.emojis)
= account_badge(account)
- if account.note.present?
.account__header__content.emojify= prerender_custom_emojis(account_bio_format(account), account.emojis)
.public-account-bio__extra
= t 'accounts.joined', date: l(account.created_at, format: :month)

@ -1,43 +0,0 @@
.public-account-header{:class => ("inactive" if account.moved?)}
.public-account-header__image
= image_tag (prefers_autoplay? ? account.header_original_url : account.header_static_url), class: 'parallax'
.public-account-header__bar
= link_to short_account_url(account), class: 'avatar' do
= image_tag (prefers_autoplay? ? account.avatar_original_url : account.avatar_static_url), id: 'profile_page_avatar', data: { original: full_asset_url(account.avatar_original_url), static: full_asset_url(account.avatar_static_url), autoplay: prefers_autoplay? }
.public-account-header__tabs
.public-account-header__tabs__name
%h1
= display_name(account, custom_emojify: true)
%small
= acct(account)
= fa_icon('lock') if account.locked?
.public-account-header__tabs__tabs
.details-counters
.counter{ class: active_nav_class(short_account_url(account), short_account_with_replies_url(account), short_account_media_url(account)) }
= link_to short_account_url(account), class: 'u-url u-uid', title: number_with_delimiter(account.statuses_count) do
%span.counter-number= friendly_number_to_human account.statuses_count
%span.counter-label= t('accounts.posts', count: account.statuses_count)
.counter{ class: active_nav_class(account_following_index_url(account)) }
= link_to account_following_index_url(account), title: number_with_delimiter(account.following_count) do
%span.counter-number= friendly_number_to_human account.following_count
%span.counter-label= t('accounts.following', count: account.following_count)
.counter{ class: active_nav_class(account_followers_url(account)) }
= link_to account_followers_url(account), title: number_with_delimiter(account.followers_count) do
%span.counter-number= friendly_number_to_human account.followers_count
%span.counter-label= t('accounts.followers', count: account.followers_count)
.spacer
.public-account-header__tabs__tabs__buttons
= account_action_button(account)
.public-account-header__extra
= render 'accounts/bio', account: account
.public-account-header__extra__links
= link_to account_following_index_url(account) do
%strong= friendly_number_to_human account.following_count
= t('accounts.following', count: account.following_count)
= link_to account_followers_url(account) do
%strong= friendly_number_to_human account.followers_count
= t('accounts.followers', count: account.followers_count)

@ -1,20 +0,0 @@
- moved_to_account = account.moved_to_account
.moved-account-widget
.moved-account-widget__message
= fa_icon 'suitcase'
= t('accounts.moved_html', name: content_tag(:bdi, content_tag(:strong, display_name(account, custom_emojify: true), class: :emojify)), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.pretty_acct)])), ActivityPub::TagManager.instance.url_for(moved_to_account), class: 'mention'))
.moved-account-widget__card
= link_to ActivityPub::TagManager.instance.url_for(moved_to_account), class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'me noopener noreferrer' do
.detailed-status__display-avatar
.account__avatar-overlay
.account__avatar-overlay-base
= image_tag moved_to_account.avatar_static_url
.account__avatar-overlay-overlay
= image_tag account.avatar_static_url
%span.display-name
%bdi
%strong.emojify= display_name(moved_to_account, custom_emojify: true)
%span @#{moved_to_account.pretty_acct}

@ -2,85 +2,13 @@
= "#{display_name(@account)} (#{acct(@account)})"
- content_for :header_tags do
- if @account.user&.setting_noindex
- if @account.user_prefers_noindex?
%meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'alternate', type: 'application/rss+xml', href: @rss_url }/
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/
- if @older_url
%link{ rel: 'next', href: @older_url }/
- if @newer_url
%link{ rel: 'prev', href: @newer_url }/
= opengraph 'og:type', 'profile'
= render 'og', account: @account, url: short_account_url(@account, only_path: false)
= render 'header', account: @account, with_bio: true
.grid
.column-0
.h-feed
%data.p-name{ value: "#{@account.username} on #{site_hostname}" }/
.account__section-headline
= active_link_to t('accounts.posts_tab_heading'), short_account_url(@account)
= active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account)
= active_link_to t('accounts.media'), short_account_media_url(@account)
- if user_signed_in? && @account.blocking?(current_account)
.nothing-here.nothing-here--under-tabs= t('accounts.unavailable')
- elsif @statuses.empty?
= nothing_here 'nothing-here--under-tabs'
- else
.activity-stream.activity-stream--under-tabs
- if params[:page].to_i.zero?
= render partial: 'statuses/status', collection: @pinned_statuses, as: :status, locals: { pinned: true }
- if @newer_url
.entry= link_to_newer @newer_url
= render partial: 'statuses/status', collection: @statuses, as: :status
- if @older_url
.entry= link_to_older @older_url
.column-1
- if @account.memorial?
.memoriam-widget= t('in_memoriam_html')
- elsif @account.moved?
= render 'moved', account: @account
= render 'bio', account: @account
- if @endorsed_accounts.empty? && @account.id == current_account&.id
.placeholder-widget= t('accounts.endorsements_hint')
- elsif !@endorsed_accounts.empty?
.endorsements-widget
%h4= t 'accounts.choices_html', name: content_tag(:bdi, display_name(@account, custom_emojify: true))
- @endorsed_accounts.each do |account|
= account_link_to account
- if @featured_hashtags.empty? && @account.id == current_account&.id
.placeholder-widget
= t('accounts.featured_tags_hint')
= link_to settings_featured_tags_path do
= t('featured_tags.add_new')
= fa_icon 'chevron-right fw'
- else
- @featured_hashtags.each do |featured_tag|
.directory__tag{ class: params[:tag] == featured_tag.name ? 'active' : nil }
= link_to short_account_tag_path(@account, featured_tag.tag) do
%h4
= fa_icon 'hashtag'
= featured_tag.display_name
%small
- if featured_tag.last_status_at.nil?
= t('accounts.nothing_here')
- else
%time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
.trends__item__current= friendly_number_to_human featured_tag.statuses_count
= render 'application/sidebar'
= render partial: 'shared/web_app'

@ -1,20 +1,6 @@
- content_for :page_title do
= t('accounts.people_who_follow', name: display_name(@account))
- content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/
= render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
= render 'accounts/header', account: @account
- if @account.hide_collections?
.nothing-here= t('accounts.network_hidden')
- elsif user_signed_in? && @account.blocking?(current_account)
.nothing-here= t('accounts.unavailable')
- elsif @follows.empty?
= nothing_here
- else
.card-grid
= render partial: 'application/card', collection: @follows.map(&:account), as: :account
= render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
= paginate @follows
= render 'shared/web_app'

@ -1,20 +1,6 @@
- content_for :page_title do
= t('accounts.people_followed_by', name: display_name(@account))
- content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/
= render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
= render 'accounts/header', account: @account
- if @account.hide_collections?
.nothing-here= t('accounts.network_hidden')
- elsif user_signed_in? && @account.blocking?(current_account)
.nothing-here= t('accounts.unavailable')
- elsif @follows.empty?
= nothing_here
- else
.card-grid
= render partial: 'application/card', collection: @follows.map(&:target_account), as: :account
= render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
= paginate @follows
= render 'shared/web_app'

@ -1,4 +1,7 @@
- content_for :header_tags do
- unless request.path == '/'
%meta{ name: 'robots', content: 'noindex' }/
= render partial: 'shared/og'
= render 'shared/web_app'

@ -1,60 +0,0 @@
- content_for :header_tags do
= render_initial_state
= javascript_pack_tag 'public', crossorigin: 'anonymous'
- content_for :content do
.public-layout
- unless @hide_navbar
.container
%nav.header
.nav-left
= link_to root_url, class: 'brand' do
= logo_as_symbol(:wordmark)
- unless whitelist_mode?
= link_to t('about.about_this'), about_more_path, class: 'nav-link optional'
= link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional'
.nav-center
.nav-right
- if user_signed_in?
= link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn'
- else
= link_to_login t('auth.login'), class: 'webapp-btn nav-link nav-button'
= link_to t('auth.register'), available_sign_up_path, class: 'webapp-btn nav-link nav-button'
.container= yield
.container
.footer
.grid
.column-0
%h4= t 'footer.resources'
%ul
%li= link_to t('about.privacy_policy'), privacy_policy_path
.column-1
%h4= t 'footer.developers'
%ul
%li= link_to t('about.documentation'), 'https://docs.joinmastodon.org/'
%li= link_to t('about.api'), 'https://docs.joinmastodon.org/client/intro/'
.column-2
%h4= link_to t('about.what_is_mastodon'), 'https://joinmastodon.org/'
= link_to logo_as_symbol, root_url, class: 'brand'
.column-3
%h4= site_hostname
%ul
- unless whitelist_mode?
%li= link_to t('about.about_this'), about_more_path
%li= "v#{Mastodon::Version.to_s}"
.column-4
%h4= t 'footer.more'
%ul
%li= link_to t('about.source_code'), Mastodon::Version.source_url
%li= link_to t('about.apps'), 'https://joinmastodon.org/apps'
.legal-xs
= link_to "v#{Mastodon::Version.to_s}", Mastodon::Version.source_url
·
= link_to t('about.privacy_policy'), privacy_policy_path
= render template: 'layouts/application'

@ -1,4 +1,7 @@
- content_for :page_title do
= t('privacy_policy.title')
- content_for :header_tags do
= render partial: 'shared/og'
= render 'shared/web_app'

@ -1,20 +0,0 @@
- content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/
.form-container
.follow-prompt
%h2= t('remote_follow.prompt')
= render partial: 'application/card', locals: { account: @account }
= simple_form_for @remote_follow, as: :remote_follow, url: account_remote_follow_path(@account) do |f|
= render 'shared/error_messages', object: @remote_follow
= f.input :acct, placeholder: t('remote_follow.acct'), input_html: { autocapitalize: 'none', autocorrect: 'off' }
.actions
= f.button :button, t('remote_follow.proceed'), type: :submit
%p.hint.subtle-hint
= t('remote_follow.reason_html', instance: site_hostname)
= t('remote_follow.no_account_html', sign_up_path: available_sign_up_path)

@ -1,24 +0,0 @@
- content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/
.form-container
.follow-prompt
%h2= t("remote_interaction.#{@interaction_type}.prompt")
.public-layout
.activity-stream.activity-stream--highlighted
= render 'statuses/status', status: @status
= simple_form_for @remote_follow, as: :remote_follow, url: remote_interaction_path(@status) do |f|
= render 'shared/error_messages', object: @remote_follow
= hidden_field_tag :type, @interaction_type
= f.input :acct, placeholder: t('remote_follow.acct'), input_html: { autocapitalize: 'none', autocorrect: 'off' }
.actions
= f.button :button, t("remote_interaction.#{@interaction_type}.proceed"), type: :submit
%p.hint.subtle-hint
= t('remote_follow.reason_html', instance: site_hostname)
= t('remote_follow.no_account_html', sign_up_path: available_sign_up_path)

@ -56,7 +56,7 @@
- else
= link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener noreferrer'
·
= link_to remote_interaction_path(status, type: :reply), class: 'modal-button detailed-status__link' do
%span.detailed-status__link
- if status.in_reply_to_id.nil?
= fa_icon('reply')
- else
@ -65,12 +65,12 @@
= " "
·
- if status.public_visibility? || status.unlisted_visibility?
= link_to remote_interaction_path(status, type: :reblog), class: 'modal-button detailed-status__link' do
%span.detailed-status__link
= fa_icon('retweet')
%span.detailed-status__reblogs>= friendly_number_to_human status.reblogs_count
= " "
·
= link_to remote_interaction_path(status, type: :favourite), class: 'modal-button detailed-status__link' do
%span.detailed-status__link
= fa_icon('star')
%span.detailed-status__favorites>= friendly_number_to_human status.favourites_count
= " "

@ -53,18 +53,18 @@
= t 'statuses.show_thread'
.status__action-bar
= link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button icon-button--with-counter modal-button' do
%span.status__action-bar-button.icon-button.icon-button--with-counter
- if status.in_reply_to_id.nil?
= fa_icon 'reply fw'
- else
= fa_icon 'reply-all fw'
%span.icon-button__counter= obscured_counter status.replies_count
= link_to remote_interaction_path(status, type: :reblog), class: 'status__action-bar-button icon-button modal-button' do
%span.status__action-bar-button.icon-button
- if status.distributable?
= fa_icon 'retweet fw'
- elsif status.private_visibility? || status.limited_visibility?
= fa_icon 'lock fw'
- else
= fa_icon 'at fw'
= link_to remote_interaction_path(status, type: :favourite), class: 'status__action-bar-button icon-button modal-button' do
%span.status__action-bar-button.icon-button
= fa_icon 'star fw'

@ -2,7 +2,7 @@
= t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false))
- content_for :header_tags do
- if @account.user&.setting_noindex
- if @account.user_prefers_noindex?
%meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/

@ -0,0 +1,5 @@
- content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/
= render partial: 'shared/og'
= render partial: 'shared/web_app'

@ -2,47 +2,26 @@
en:
about:
about_mastodon_html: 'The social network of the future: No ads, no corporate surveillance, ethical design, and decentralization! Own your data with Mastodon!'
api: API
apps: Mobile apps
contact_missing: Not set
contact_unavailable: N/A
documentation: Documentation
hosted_on: Mastodon hosted on %{domain}
privacy_policy: Privacy Policy
source_code: Source code
title: About
what_is_mastodon: What is Mastodon?
accounts:
choices_html: "%{name}'s choices:"
endorsements_hint: You can endorse people you follow from the web interface, and they will show up here.
featured_tags_hint: You can feature specific hashtags that will be displayed here.
follow: Follow
followers:
one: Follower
other: Followers
following: Following
instance_actor_flash: This account is a virtual actor used to represent the server itself and not any individual user. It is used for federation purposes and should not be suspended.
joined: Joined %{date}
last_active: last active
link_verified_on: Ownership of this link was checked on %{date}
media: Media
moved_html: "%{name} has moved to %{new_profile_link}:"
network_hidden: This information is not available
nothing_here: There is nothing here!
people_followed_by: People whom %{name} follows
people_who_follow: People who follow %{name}
pin_errors:
following: You must be already following the person you want to endorse
posts:
one: Post
other: Posts
posts_tab_heading: Posts
posts_with_replies: Posts and replies
roles:
bot: Bot
group: Group
unavailable: Profile unavailable
unfollow: Unfollow
admin:
account_actions:
action: Perform action
@ -1176,9 +1155,6 @@ en:
hint: This filter applies to select individual posts regardless of other criteria. You can add more posts to this filter from the web interface.
title: Filtered posts
footer:
developers: Developers
more: More…
resources: Resources
trending_now: Trending now
generic:
all: All
@ -1221,7 +1197,6 @@ en:
following: Following list
muting: Muting list
upload: Upload
in_memoriam_html: In Memoriam.
invites:
delete: Deactivate
expired: Expired
@ -1402,22 +1377,7 @@ en:
remove_selected_follows: Unfollow selected users
status: Account status
remote_follow:
acct: Enter your username@domain you want to act from
missing_resource: Could not find the required redirect URL for your account
no_account_html: Don't have an account? You can <a href='%{sign_up_path}' target='_blank'>sign up here</a>
proceed: Proceed to follow
prompt: 'You are going to follow:'
reason_html: "<strong>Why is this step necessary?</strong> <code>%{instance}</code> might not be the server where you are registered, so we need to redirect you to your home server first."
remote_interaction:
favourite:
proceed: Proceed to favourite
prompt: 'You want to favourite this post:'
reblog:
proceed: Proceed to boost
prompt: 'You want to boost this post:'
reply:
proceed: Proceed to reply
prompt: 'You want to reply to this post:'
reports:
errors:
invalid_rules: does not reference valid rules

@ -3,6 +3,31 @@
require 'sidekiq_unique_jobs/web'
require 'sidekiq-scheduler/web'
# Paths of routes on the web app that to not require to be indexed or
# have alternative format representations requiring separate controllers
WEB_APP_PATHS = %w(
/getting-started
/keyboard-shortcuts
/home
/public
/public/local
/conversations
/lists/(*any)
/notifications
/favourites
/bookmarks
/pinned
/start
/directory
/explore/(*any)
/search
/publish
/follow_requests
/blocks
/domain_blocks
/mutes
).freeze
Rails.application.routes.draw do
root 'home#index'
@ -59,9 +84,6 @@ Rails.application.routes.draw do
get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" }
resources :accounts, path: 'users', only: [:show], param: :username do
get :remote_follow, to: 'remote_follow#new'
post :remote_follow, to: 'remote_follow#create'
resources :statuses, only: [:show] do
member do
get :activity
@ -85,16 +107,21 @@ Rails.application.routes.draw do
resource :inbox, only: [:create], module: :activitypub
get '/@:username', to: 'accounts#show', as: :short_account
get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
get '/@:username/media', to: 'accounts#show', as: :short_account_media
get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag
get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status
get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status
constraints(username: /[^@\/.]+/) do
get '/@:username', to: 'accounts#show', as: :short_account
get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
get '/@:username/media', to: 'accounts#show', as: :short_account_media
get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag
end
get '/interact/:id', to: 'remote_interaction#new', as: :remote_interaction
post '/interact/:id', to: 'remote_interaction#create'
constraints(account_username: /[^@\/.]+/) do
get '/@:account_username/following', to: 'following_accounts#index'
get '/@:account_username/followers', to: 'follower_accounts#index'
get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status
get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status
end
get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: /([^\/])+?/ }, format: false
get '/settings', to: redirect('/settings/profile')
namespace :settings do
@ -187,9 +214,6 @@ Rails.application.routes.draw do
resource :relationships, only: [:show, :update]
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
get '/explore', to: redirect('/web/explore')
get '/public', to: redirect('/web/public')
get '/public/local', to: redirect('/web/public/local')
get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
resource :authorize_interaction, only: [:show, :create]
@ -642,8 +666,11 @@ Rails.application.routes.draw do
end
end
get '/web/(*any)', to: 'home#index', as: :web
WEB_APP_PATHS.each do |path|
get path, to: 'home#index'
end
get '/web/(*any)', to: redirect('/%{any}', status: 302), as: :web
get '/about', to: 'about#show'
get '/about/more', to: redirect('/about')

@ -115,7 +115,6 @@
"redux-immutable": "^4.0.0",
"redux-thunk": "^2.4.1",
"regenerator-runtime": "^0.13.9",
"rellax": "^1.12.1",
"requestidlecallback": "^0.3.0",
"reselect": "^4.1.6",
"rimraf": "^3.0.2",

@ -1,64 +0,0 @@
require 'rails_helper'
describe AccountFollowController do
render_views
let(:user) { Fabricate(:user) }
let(:alice) { Fabricate(:account, username: 'alice') }
describe 'POST #create' do
let(:service) { double }
subject { post :create, params: { account_username: alice.username } }
before do
allow(FollowService).to receive(:new).and_return(service)
allow(service).to receive(:call)
end
context 'when account is permanently suspended' do
before do
alice.suspend!
alice.deletion_request.destroy
subject
end
it 'returns http gone' do
expect(response).to have_http_status(410)
end
end
context 'when account is temporarily suspended' do
before do
alice.suspend!
subject
end
it 'returns http forbidden' do
expect(response).to have_http_status(403)
end
end
context 'when signed out' do
before do
subject
end
it 'does not follow' do
expect(FollowService).not_to receive(:new)
end
end
context 'when signed in' do
before do
sign_in(user)
subject
end
it 'redirects to account path' do
expect(service).to have_received(:call).with(user.account, alice, with_rate_limit: true)
expect(response).to redirect_to(account_path(alice))
end
end
end
end

@ -1,64 +0,0 @@
require 'rails_helper'
describe AccountUnfollowController do
render_views
let(:user) { Fabricate(:user) }
let(:alice) { Fabricate(:account, username: 'alice') }
describe 'POST #create' do
let(:service) { double }
subject { post :create, params: { account_username: alice.username } }
before do
allow(UnfollowService).to receive(:new).and_return(service)
allow(service).to receive(:call)
end
context 'when account is permanently suspended' do
before do
alice.suspend!
alice.deletion_request.destroy
subject
end
it 'returns http gone' do
expect(response).to have_http_status(410)
end
end
context 'when account is temporarily suspended' do
before do
alice.suspend!
subject
end
it 'returns http forbidden' do
expect(response).to have_http_status(403)
end
end
context 'when signed out' do
before do
subject
end
it 'does not unfollow' do
expect(UnfollowService).not_to receive(:new)
end
end
context 'when signed in' do
before do
sign_in(user)
subject
end
it 'redirects to account path' do
expect(service).to have_received(:call).with(user.account, alice)
expect(response).to redirect_to(account_path(alice))
end
end
end
end

@ -99,100 +99,6 @@ RSpec.describe AccountsController, type: :controller do
end
it_behaves_like 'common response characteristics'
it 'renders public status' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status))
end
it 'renders self-reply' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_self_reply))
end
it 'renders status with media' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_media))
end
it 'renders reblog' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_reblog.reblog))
end
it 'renders pinned status' do
expect(response.body).to include(I18n.t('stream_entries.pinned'))
end
it 'does not render private status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_private))
end
it 'does not render direct status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_direct))
end
it 'does not render reply to someone else' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_reply))
end
end
context 'when signed-in' do
let(:user) { Fabricate(:user) }
before do
sign_in(user)
end
context 'when user follows account' do
before do
user.account.follow!(account)
get :show, params: { username: account.username, format: format }
end
it 'does not render private status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_private))
end
end
context 'when user is blocked' do
before do
account.block!(user.account)
get :show, params: { username: account.username, format: format }
end
it 'renders unavailable message' do
expect(response.body).to include(I18n.t('accounts.unavailable'))
end
it 'does not render public status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status))
end
it 'does not render self-reply' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_self_reply))
end
it 'does not render status with media' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_media))
end
it 'does not render reblog' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_reblog.reblog))
end
it 'does not render pinned status' do
expect(response.body).to_not include(I18n.t('stream_entries.pinned'))
end
it 'does not render private status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_private))
end
it 'does not render direct status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_direct))
end
it 'does not render reply to someone else' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_reply))
end
end
end
context 'with replies' do
@ -202,38 +108,6 @@ RSpec.describe AccountsController, type: :controller do
end
it_behaves_like 'common response characteristics'
it 'renders public status' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status))
end
it 'renders self-reply' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_self_reply))
end
it 'renders status with media' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_media))
end
it 'renders reblog' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_reblog.reblog))
end
it 'does not render pinned status' do
expect(response.body).to_not include(I18n.t('stream_entries.pinned'))
end
it 'does not render private status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_private))
end
it 'does not render direct status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_direct))
end
it 'renders reply to someone else' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_reply))
end
end
context 'with media' do
@ -243,38 +117,6 @@ RSpec.describe AccountsController, type: :controller do
end
it_behaves_like 'common response characteristics'
it 'does not render public status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status))
end
it 'does not render self-reply' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_self_reply))
end
it 'renders status with media' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_media))
end
it 'does not render reblog' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_reblog.reblog))
end
it 'does not render pinned status' do
expect(response.body).to_not include(I18n.t('stream_entries.pinned'))
end
it 'does not render private status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_private))
end
it 'does not render direct status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_direct))
end
it 'does not render reply to someone else' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_reply))
end
end
context 'with tag' do
@ -289,42 +131,6 @@ RSpec.describe AccountsController, type: :controller do
end
it_behaves_like 'common response characteristics'
it 'does not render public status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status))
end
it 'does not render self-reply' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_self_reply))
end
it 'does not render status with media' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_media))
end
it 'does not render reblog' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_reblog.reblog))
end
it 'does not render pinned status' do
expect(response.body).to_not include(I18n.t('stream_entries.pinned'))
end
it 'does not render private status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_private))
end
it 'does not render direct status' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_direct))
end
it 'does not render reply to someone else' do
expect(response.body).to_not include(ActivityPub::TagManager.instance.url_for(status_reply))
end
it 'renders status with tag' do
expect(response.body).to include(ActivityPub::TagManager.instance.url_for(status_tag))
end
end
end

@ -39,7 +39,7 @@ describe AuthorizeInteractionsController do
end
it 'sets resource from url' do
account = Account.new
account = Fabricate(:account)
service = double
allow(ResolveURLService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('http://example.com').and_return(account)
@ -51,7 +51,7 @@ describe AuthorizeInteractionsController do
end
it 'sets resource from acct uri' do
account = Account.new
account = Fabricate(:account)
service = double
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('found@hostname').and_return(account)

@ -34,27 +34,6 @@ describe FollowerAccountsController do
expect(response).to have_http_status(403)
end
end
it 'assigns follows' do
expect(response).to have_http_status(200)
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 2
expect(assigned[0]).to eq follow1
expect(assigned[1]).to eq follow0
end
it 'does not assign blocked users' do
user = Fabricate(:user)
user.account.block!(follower0)
sign_in(user)
expect(response).to have_http_status(200)
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 1
expect(assigned[0]).to eq follow1
end
end
context 'when format is json' do

@ -34,27 +34,6 @@ describe FollowingAccountsController do
expect(response).to have_http_status(403)
end
end
it 'assigns follows' do
expect(response).to have_http_status(200)
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 2
expect(assigned[0]).to eq follow1
expect(assigned[1]).to eq follow0
end
it 'does not assign blocked users' do
user = Fabricate(:user)
user.account.block!(followee0)
sign_in(user)
expect(response).to have_http_status(200)
assigned = assigns(:follows).to_a
expect(assigned.size).to eq 1
expect(assigned[0]).to eq follow1
end
end
context 'when format is json' do

@ -1,135 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe RemoteFollowController do
render_views
describe '#new' do
it 'returns success when session is empty' do
account = Fabricate(:account)
get :new, params: { account_username: account.to_param }
expect(response).to have_http_status(200)
expect(response).to render_template(:new)
expect(assigns(:remote_follow).acct).to be_nil
end
it 'populates the remote follow with session data when session exists' do
session[:remote_follow] = 'user@example.com'
account = Fabricate(:account)
get :new, params: { account_username: account.to_param }
expect(response).to have_http_status(200)
expect(response).to render_template(:new)
expect(assigns(:remote_follow).acct).to eq 'user@example.com'
end
end
describe '#create' do
before do
@account = Fabricate(:account, username: 'test_user')
end
context 'with a valid acct' do
context 'when webfinger values are wrong' do
it 'renders new when redirect url is nil' do
resource_with_nil_link = double(link: nil)
allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_return(resource_with_nil_link)
post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } }
expect(response).to render_template(:new)
expect(response.body).to include(I18n.t('remote_follow.missing_resource'))
end
it 'renders new when template is nil' do
resource_with_link = double(link: nil)
allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_return(resource_with_link)
post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } }
expect(response).to render_template(:new)
expect(response.body).to include(I18n.t('remote_follow.missing_resource'))
end
end
context 'when webfinger values are good' do
before do
resource_with_link = double(link: 'http://example.com/follow_me?acct={uri}')
allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_return(resource_with_link)
post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } }
end
it 'saves the session' do
expect(session[:remote_follow]).to eq 'user@example.com'
end
it 'redirects to the remote location' do
expect(response).to redirect_to("http://example.com/follow_me?acct=https%3A%2F%2F#{Rails.configuration.x.local_domain}%2Fusers%2Ftest_user")
end
end
end
context 'with an invalid acct' do
it 'renders new when acct is missing' do
post :create, params: { account_username: @account.to_param, remote_follow: { acct: '' } }
expect(response).to render_template(:new)
end
it 'renders new with error when webfinger fails' do
allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_raise(Webfinger::Error)
post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } }
expect(response).to render_template(:new)
expect(response.body).to include(I18n.t('remote_follow.missing_resource'))
end
it 'renders new when occur HTTP::ConnectionError' do
allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@unknown').and_raise(HTTP::ConnectionError)
post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@unknown' } }
expect(response).to render_template(:new)
expect(response.body).to include(I18n.t('remote_follow.missing_resource'))
end
end
end
context 'with a permanently suspended account' do
before do
@account = Fabricate(:account)
@account.suspend!
@account.deletion_request.destroy
end
it 'returns http gone on GET to #new' do
get :new, params: { account_username: @account.to_param }
expect(response).to have_http_status(410)
end
it 'returns http gone on POST to #create' do
post :create, params: { account_username: @account.to_param }
expect(response).to have_http_status(410)
end
end
context 'with a temporarily suspended account' do
before do
@account = Fabricate(:account)
@account.suspend!
end
it 'returns http forbidden on GET to #new' do
get :new, params: { account_username: @account.to_param }
expect(response).to have_http_status(403)
end
it 'returns http forbidden on POST to #create' do
post :create, params: { account_username: @account.to_param }
expect(response).to have_http_status(403)
end
end
end

@ -1,39 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe RemoteInteractionController, type: :controller do
render_views
let(:status) { Fabricate(:status) }
describe 'GET #new' do
it 'returns 200' do
get :new, params: { id: status.id }
expect(response).to have_http_status(200)
end
end
describe 'POST #create' do
context '@remote_follow is valid' do
it 'returns 302' do
allow_any_instance_of(RemoteFollow).to receive(:valid?) { true }
allow_any_instance_of(RemoteFollow).to receive(:addressable_template) do
Addressable::Template.new('https://hoge.com')
end
post :create, params: { id: status.id, remote_follow: { acct: '@hoge' } }
expect(response).to have_http_status(302)
end
end
context '@remote_follow is invalid' do
it 'returns 200' do
allow_any_instance_of(RemoteFollow).to receive(:valid?) { false }
post :create, params: { id: status.id, remote_follow: { acct: '@hoge' } }
expect(response).to have_http_status(200)
end
end
end
end

@ -10,16 +10,15 @@ RSpec.describe TagsController, type: :controller do
let!(:late) { Fabricate(:status, tags: [tag], text: 'late #test') }
context 'when tag exists' do
it 'redirects to web version' do
it 'returns http success' do
get :show, params: { id: 'test', max_id: late.id }
expect(response).to redirect_to('/web/tags/test')
expect(response).to have_http_status(200)
end
end
context 'when tag does not exist' do
it 'returns http missing for non-existent tag' do
it 'returns http not found' do
get :show, params: { id: 'none' }
expect(response).to have_http_status(404)
end
end

@ -18,36 +18,16 @@ feature 'Profile' do
visit account_path('alice')
is_expected.to have_title("alice (@alice@#{local_domain})")
within('.public-account-header h1') do
is_expected.to have_content("alice @alice@#{local_domain}")
end
bio_elem = first('.public-account-bio')
expect(bio_elem).to have_content(alice_bio)
# The bio has hashtags made clickable
expect(bio_elem).to have_link('cryptology')
expect(bio_elem).to have_link('science')
# Nicknames are make clickable
expect(bio_elem).to have_link('@alice')
expect(bio_elem).to have_link('@bob')
# Nicknames not on server are not clickable
expect(bio_elem).not_to have_link('@pepe')
end
scenario 'I can change my account' do
visit settings_profile_path
fill_in 'Display name', with: 'Bob'
fill_in 'Bio', with: 'Bob is silent'
first('.btn[type=submit]').click
is_expected.to have_content 'Changes successfully saved!'
# View my own public profile and see the changes
click_link "Bob @bob@#{local_domain}"
first('button[type=submit]').click
within('.public-account-header h1') do
is_expected.to have_content("Bob @bob@#{local_domain}")
end
expect(first('.public-account-bio')).to have_content('Bob is silent')
is_expected.to have_content 'Changes successfully saved!'
end
end

@ -3,40 +3,31 @@
require 'rails_helper'
describe PermalinkRedirector do
let(:remote_account) { Fabricate(:account, username: 'alice', domain: 'example.com', url: 'https://example.com/@alice', id: 2) }
describe '#redirect_url' do
before do
account = Fabricate(:account, username: 'alice', id: 1)
Fabricate(:status, account: account, id: 123)
Fabricate(:status, account: remote_account, id: 123, url: 'https://example.com/status-123')
end
it 'returns path for legacy account links' do
redirector = described_class.new('web/accounts/1')
expect(redirector.redirect_path).to eq 'https://cb6e6126.ngrok.io/@alice'
redirector = described_class.new('accounts/2')
expect(redirector.redirect_path).to eq 'https://example.com/@alice'
end
it 'returns path for legacy status links' do
redirector = described_class.new('web/statuses/123')
expect(redirector.redirect_path).to eq 'https://cb6e6126.ngrok.io/@alice/123'
end
it 'returns path for legacy tag links' do
redirector = described_class.new('web/timelines/tag/hoge')
expect(redirector.redirect_path).to be_nil
redirector = described_class.new('statuses/123')
expect(redirector.redirect_path).to eq 'https://example.com/status-123'
end
it 'returns path for pretty account links' do
redirector = described_class.new('web/@alice')
expect(redirector.redirect_path).to eq 'https://cb6e6126.ngrok.io/@alice'
redirector = described_class.new('@alice@example.com')
expect(redirector.redirect_path).to eq 'https://example.com/@alice'
end
it 'returns path for pretty status links' do
redirector = described_class.new('web/@alice/123')
expect(redirector.redirect_path).to eq 'https://cb6e6126.ngrok.io/@alice/123'
end
it 'returns path for pretty tag links' do
redirector = described_class.new('web/tags/hoge')
expect(redirector.redirect_path).to be_nil
redirector = described_class.new('@alice/123')
expect(redirector.redirect_path).to eq 'https://example.com/status-123'
end
end
end

@ -3,17 +3,6 @@
require 'rails_helper'
describe 'The account show page' do
it 'Has an h-feed with correct number of h-entry objects in it' do
alice = Fabricate(:account, username: 'alice', display_name: 'Alice')
_status = Fabricate(:status, account: alice, text: 'Hello World')
_status2 = Fabricate(:status, account: alice, text: 'Hello World Again')
_status3 = Fabricate(:status, account: alice, text: 'Are You Still There World?')
get '/@alice'
expect(h_feed_entries.size).to eq(3)
end
it 'has valid opengraph tags' do
alice = Fabricate(:account, username: 'alice', display_name: 'Alice')
_status = Fabricate(:status, account: alice, text: 'Hello World')
@ -33,8 +22,4 @@ describe 'The account show page' do
def head_section
Nokogiri::Slop(response.body).html.head
end
def h_feed_entries
Nokogiri::HTML(response.body).search('.h-feed .h-entry')
end
end

@ -1,31 +1,83 @@
require 'rails_helper'
describe 'Routes under accounts/' do
describe 'the route for accounts who are followers of an account' do
it 'routes to the followers action with the right username' do
expect(get('/users/name/followers')).
to route_to('follower_accounts#index', account_username: 'name')
context 'with local username' do
let(:username) { 'alice' }
it 'routes /@:username' do
expect(get("/@#{username}")).to route_to('accounts#show', username: username)
end
end
describe 'the route for accounts who are followed by an account' do
it 'routes to the following action with the right username' do
expect(get('/users/name/following')).
to route_to('following_accounts#index', account_username: 'name')
it 'routes /@:username.json' do
expect(get("/@#{username}.json")).to route_to('accounts#show', username: username, format: 'json')
end
it 'routes /@:username.rss' do
expect(get("/@#{username}.rss")).to route_to('accounts#show', username: username, format: 'rss')
end
it 'routes /@:username/:id' do
expect(get("/@#{username}/123")).to route_to('statuses#show', account_username: username, id: '123')
end
it 'routes /@:username/:id/embed' do
expect(get("/@#{username}/123/embed")).to route_to('statuses#embed', account_username: username, id: '123')
end
it 'routes /@:username/following' do
expect(get("/@#{username}/following")).to route_to('following_accounts#index', account_username: username)
end
it 'routes /@:username/followers' do
expect(get("/@#{username}/followers")).to route_to('follower_accounts#index', account_username: username)
end
it 'routes /@:username/with_replies' do
expect(get("/@#{username}/with_replies")).to route_to('accounts#show', username: username)
end
it 'routes /@:username/media' do
expect(get("/@#{username}/media")).to route_to('accounts#show', username: username)
end
end
describe 'the route for following an account' do
it 'routes to the follow create action with the right username' do
expect(post('/users/name/follow')).
to route_to('account_follow#create', account_username: 'name')
it 'routes /@:username/tagged/:tag' do
expect(get("/@#{username}/tagged/foo")).to route_to('accounts#show', username: username, tag: 'foo')
end
end
describe 'the route for unfollowing an account' do
it 'routes to the unfollow create action with the right username' do
expect(post('/users/name/unfollow')).
to route_to('account_unfollow#create', account_username: 'name')
context 'with remote username' do
let(:username) { 'alice@example.com' }
it 'routes /@:username' do
expect(get("/@#{username}")).to route_to('home#index', username_with_domain: username)
end
it 'routes /@:username/:id' do
expect(get("/@#{username}/123")).to route_to('home#index', username_with_domain: username, any: '123')
end
it 'routes /@:username/:id/embed' do
expect(get("/@#{username}/123/embed")).to route_to('home#index', username_with_domain: username, any: '123/embed')
end
it 'routes /@:username/following' do
expect(get("/@#{username}/following")).to route_to('home#index', username_with_domain: username, any: 'following')
end
it 'routes /@:username/followers' do
expect(get("/@#{username}/followers")).to route_to('home#index', username_with_domain: username, any: 'followers')
end
it 'routes /@:username/with_replies' do
expect(get("/@#{username}/with_replies")).to route_to('home#index', username_with_domain: username, any: 'with_replies')
end
it 'routes /@:username/media' do
expect(get("/@#{username}/media")).to route_to('home#index', username_with_domain: username, any: 'media')
end
it 'routes /@:username/tagged/:tag' do
expect(get("/@#{username}/tagged/foo")).to route_to('home#index', username_with_domain: username, any: 'tagged/foo')
end
end
end

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save