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
remotes/1703361221475462875/rebase/4.0.0rc1
Eugen Rochko 2022-10-20 14:35:29 +02:00 committed by GitHub
parent b0e3f0312c
commit 839f893168
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 393 additions and 2468 deletions

View File

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

View File

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

View File

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

View File

@ -9,7 +9,6 @@ class AccountsController < ApplicationController
before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_account_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_body_classes
skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) } skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
skip_before_action :require_functional!, unless: :whitelist_mode? skip_before_action :require_functional!, unless: :whitelist_mode?
@ -18,24 +17,6 @@ class AccountsController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
expires_in 0, public: true unless user_signed_in? 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 end
format.rss do format.rss do
@ -55,18 +36,6 @@ class AccountsController < ApplicationController
private 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 def filtered_statuses
default_statuses.tap do |statuses| default_statuses.tap do |statuses|
statuses.merge!(hashtag_scope) if tag_requested? statuses.merge!(hashtag_scope) if tag_requested?
@ -113,26 +82,6 @@ class AccountsController < ApplicationController
end end
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? def media_requested?
request.path.split('.').first.end_with?('/media') && !tag_requested? request.path.split('.').first.end_with?('/media') && !tag_requested?
end end
@ -145,13 +94,6 @@ class AccountsController < ApplicationController
request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end end
def cached_filtered_status_pins
cache_collection(
filtered_pinned_statuses,
Status
)
end
def cached_filtered_status_page def cached_filtered_status_page
cache_collection_paginated_by_id( cache_collection_paginated_by_id(
filtered_statuses, filtered_statuses,

View File

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

View File

@ -4,15 +4,24 @@ module WebAppControllerConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do 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 before_action :set_referrer_policy_header
end end
def set_body_classes def set_app_body_class
@body_classes = 'app-body' @body_classes = 'app-body'
end end
def set_referrer_policy_header def set_referrer_policy_header
response.headers['Referrer-Policy'] = 'origin' response.headers['Referrer-Policy'] = 'origin'
end 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 end

View File

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

View File

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

View File

@ -3,21 +3,14 @@
class HomeController < ApplicationController class HomeController < ApplicationController
include WebAppControllerConcern include WebAppControllerConcern
before_action :redirect_unauthenticated_to_permalinks!
before_action :set_instance_presenter before_action :set_instance_presenter
def index; end def index
expires_in 0, public: true unless user_signed_in?
end
private 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 def set_instance_presenter
@instance_presenter = InstancePresenter.new @instance_presenter = InstancePresenter.new
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,54 +20,10 @@ module AccountsHelper
end end
def account_action_button(account) def account_action_button(account)
if user_signed_in? return if account.memorial? || account.moved?
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) link_to ActivityPub::TagManager.instance.url_for(account), class: 'button logo-button', target: '_new' do
if user_signed_in? safe_join([logo_as_symbol, t('accounts.follow')])
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
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')
end end
end end

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { version, source_url } from 'mastodon/initial_state'; import { version, source_url } from 'mastodon/initial_state';
import StackTrace from 'stacktrace-js'; import StackTrace from 'stacktrace-js';
import { Helmet } from 'react-helmet';
export default class ErrorBoundary extends React.PureComponent { 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.' /> <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>
<p> <p>
{ likelyBrowserAddonIssue ? ( { 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.' /> <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.' /> <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>
<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> <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> </div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -94,6 +94,7 @@ class About extends React.PureComponent {
}), }),
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
}; };
componentDidMount () { componentDidMount () {
@ -108,11 +109,11 @@ class About extends React.PureComponent {
} }
render () { render () {
const { intl, server, extendedDescription, domainBlocks } = this.props; const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
const isLoading = server.get('isLoading'); const isLoading = server.get('isLoading');
return ( return (
<Column> <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<div className='scrollable about'> <div className='scrollable about'>
<div className='about__header'> <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' /> <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> <Helmet>
<title>{intl.formatMessage(messages.title)}</title> <title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet> </Helmet>
</Column> </Column>
); );

View File

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

View File

@ -142,7 +142,13 @@ class AccountTimeline extends ImmutablePureComponent {
render () { render () {
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props; const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) { if (isLoading && statusIds.isEmpty()) {
return (
<Column>
<LoadingIndicator />
</Column>
);
} else if (!isLoading && !isAccount) {
return ( return (
<Column> <Column>
<ColumnBackButton multiColumn={multiColumn} /> <ColumnBackButton multiColumn={multiColumn} />
@ -151,14 +157,6 @@ class AccountTimeline extends ImmutablePureComponent {
); );
} }
if (!statusIds && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage; let emptyMessage;
const forceEmptyState = suspended || blockedBy || hidden; const forceEmptyState = suspended || blockedBy || hidden;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }, title: { id: 'column.home', defaultMessage: 'Home' },
show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' }, show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' }, hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
@ -167,6 +167,7 @@ class HomeTimeline extends React.PureComponent {
<Helmet> <Helmet>
<title>{intl.formatMessage(messages.title)}</title> <title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet> </Helmet>
</Column> </Column>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -166,6 +166,14 @@ export function FilterModal () {
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal'); 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 () { export function About () {
return import(/*webpackChunkName: "features/about" */'../../about'); return import(/*webpackChunkName: "features/about" */'../../about');
} }

View File

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

View File

@ -12,14 +12,6 @@ const perf = require('mastodon/performance');
function main() { function main() {
perf.start('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 () => { return ready(async () => {
const mountNode = document.getElementById('mastodon'); const mountNode = document.getElementById('mastodon');
const props = JSON.parse(mountNode.getAttribute('data-props')); const props = JSON.parse(mountNode.getAttribute('data-props'));

View File

@ -15,6 +15,8 @@ import {
STATUS_COLLAPSE, STATUS_COLLAPSE,
STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO, STATUS_TRANSLATE_UNDO,
STATUS_FETCH_REQUEST,
STATUS_FETCH_FAIL,
} from '../actions/statuses'; } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
@ -37,6 +39,10 @@ const initialState = ImmutableMap();
export default function statuses(state = initialState, action) { export default function statuses(state = initialState, action) {
switch(action.type) { 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: case STATUS_IMPORT:
return importStatus(state, action.status); return importStatus(state, action.status);
case STATUSES_IMPORT: case STATUSES_IMPORT:

View File

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

View File

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

View File

@ -33,7 +33,6 @@ function main() {
const { messages } = getLocale(); const { messages } = getLocale();
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
const Rellax = require('rellax');
const { createBrowserHistory } = require('history'); const { createBrowserHistory } = require('history');
const scrollToDetailedStatus = () => { const scrollToDetailedStatus = () => {
@ -112,12 +111,6 @@ function main() {
scrollToDetailedStatus(); 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', () => { delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
const password = document.getElementById('registration_user_password'); const password = document.getElementById('registration_user_password');
const confirmation = document.getElementById('registration_user_password_confirmation'); 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 }) => { delegate(document, '#account_display_name', 'input', ({ target }) => {
const name = document.querySelector('.card .display-name strong'); const name = document.querySelector('.card .display-name strong');
if (name) { if (name) {

View File

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

View File

@ -68,10 +68,6 @@
color: $darker-text-color; 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 .autosuggest-textarea__textarea::placeholder,
.compose-form .spoiler-input__input::placeholder { .compose-form .spoiler-input__input::placeholder {
color: $inverted-text-color; color: $inverted-text-color;

View File

@ -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, .notification__filter-bar button.active::after,
.account__section-headline a.active::after { .account__section-headline a.active::after {
border-color: transparent transparent $white; border-color: transparent transparent $white;

View File

@ -104,785 +104,3 @@
margin-left: 10px; 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%);
}
}
}
}
}
}

View File

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

View File

@ -53,16 +53,6 @@ body.rtl {
right: -26px; 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-link__icon,
.column-header__icon { .column-header__icon {
margin-right: 0; margin-right: 0;
@ -350,44 +340,6 @@ body.rtl {
margin-left: 45px; 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) { @media screen and (min-width: 631px) {
.column, .column,
.drawer { .drawer {
@ -415,32 +367,6 @@ body.rtl {
padding-right: 0; 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 { .card__bar .display-name {
margin-left: 0; margin-left: 0;
margin-right: 15px; margin-right: 15px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,85 +2,13 @@
= "#{display_name(@account)} (#{acct(@account)})" = "#{display_name(@account)} (#{acct(@account)})"
- content_for :header_tags do - content_for :header_tags do
- if @account.user&.setting_noindex - if @account.user_prefers_noindex?
%meta{ name: 'robots', content: 'noindex, noarchive' }/ %meta{ name: 'robots', content: 'noindex, noarchive' }/
%link{ rel: 'alternate', type: 'application/rss+xml', href: @rss_url }/ %link{ rel: 'alternate', type: 'application/rss+xml', href: @rss_url }/
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/ %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' = opengraph 'og:type', 'profile'
= render 'og', account: @account, url: short_account_url(@account, only_path: false) = render 'og', account: @account, url: short_account_url(@account, only_path: false)
= render partial: 'shared/web_app'
= 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'

View File

@ -1,20 +1,6 @@
- content_for :page_title do
= t('accounts.people_who_follow', name: display_name(@account))
- content_for :header_tags do - content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/ %meta{ name: 'robots', content: 'noindex' }/
= render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false) = render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
= render 'accounts/header', account: @account = render 'shared/web_app'
- 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
= paginate @follows

View File

@ -1,20 +1,6 @@
- content_for :page_title do
= t('accounts.people_followed_by', name: display_name(@account))
- content_for :header_tags do - content_for :header_tags do
%meta{ name: 'robots', content: 'noindex' }/ %meta{ name: 'robots', content: 'noindex' }/
= render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false) = render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false)
= render 'accounts/header', account: @account = render 'shared/web_app'
- 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
= paginate @follows

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -53,18 +53,18 @@
= t 'statuses.show_thread' = t 'statuses.show_thread'
.status__action-bar .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? - if status.in_reply_to_id.nil?
= fa_icon 'reply fw' = fa_icon 'reply fw'
- else - else
= fa_icon 'reply-all fw' = fa_icon 'reply-all fw'
%span.icon-button__counter= obscured_counter status.replies_count %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? - if status.distributable?
= fa_icon 'retweet fw' = fa_icon 'retweet fw'
- elsif status.private_visibility? || status.limited_visibility? - elsif status.private_visibility? || status.limited_visibility?
= fa_icon 'lock fw' = fa_icon 'lock fw'
- else - else
= fa_icon 'at fw' = 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' = fa_icon 'star fw'

View File

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

View File

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

View File

@ -2,47 +2,26 @@
en: en:
about: about:
about_mastodon_html: 'The social network of the future: No ads, no corporate surveillance, ethical design, and decentralization! Own your data with Mastodon!' 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_missing: Not set
contact_unavailable: N/A contact_unavailable: N/A
documentation: Documentation
hosted_on: Mastodon hosted on %{domain} hosted_on: Mastodon hosted on %{domain}
privacy_policy: Privacy Policy
source_code: Source code
title: About title: About
what_is_mastodon: What is Mastodon?
accounts: 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 follow: Follow
followers: followers:
one: Follower one: Follower
other: Followers other: Followers
following: Following 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. 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 last_active: last active
link_verified_on: Ownership of this link was checked on %{date} 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! nothing_here: There is nothing here!
people_followed_by: People whom %{name} follows
people_who_follow: People who follow %{name}
pin_errors: pin_errors:
following: You must be already following the person you want to endorse following: You must be already following the person you want to endorse
posts: posts:
one: Post one: Post
other: Posts other: Posts
posts_tab_heading: Posts posts_tab_heading: Posts
posts_with_replies: Posts and replies
roles:
bot: Bot
group: Group
unavailable: Profile unavailable
unfollow: Unfollow
admin: admin:
account_actions: account_actions:
action: Perform action 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. 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 title: Filtered posts
footer: footer:
developers: Developers
more: More…
resources: Resources
trending_now: Trending now trending_now: Trending now
generic: generic:
all: All all: All
@ -1221,7 +1197,6 @@ en:
following: Following list following: Following list
muting: Muting list muting: Muting list
upload: Upload upload: Upload
in_memoriam_html: In Memoriam.
invites: invites:
delete: Deactivate delete: Deactivate
expired: Expired expired: Expired
@ -1402,22 +1377,7 @@ en:
remove_selected_follows: Unfollow selected users remove_selected_follows: Unfollow selected users
status: Account status status: Account status
remote_follow: remote_follow:
acct: Enter your username@domain you want to act from
missing_resource: Could not find the required redirect URL for your account 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: reports:
errors: errors:
invalid_rules: does not reference valid rules invalid_rules: does not reference valid rules

View File

@ -3,6 +3,31 @@
require 'sidekiq_unique_jobs/web' require 'sidekiq_unique_jobs/web'
require 'sidekiq-scheduler/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 Rails.application.routes.draw do
root 'home#index' root 'home#index'
@ -59,9 +84,6 @@ Rails.application.routes.draw do
get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" }
resources :accounts, path: 'users', only: [:show], param: :username do 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 resources :statuses, only: [:show] do
member do member do
get :activity get :activity
@ -85,16 +107,21 @@ Rails.application.routes.draw do
resource :inbox, only: [:create], module: :activitypub resource :inbox, only: [:create], module: :activitypub
get '/@:username', to: 'accounts#show', as: :short_account constraints(username: /[^@\/.]+/) do
get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies get '/@:username', to: 'accounts#show', as: :short_account
get '/@:username/media', to: 'accounts#show', as: :short_account_media get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag get '/@:username/media', to: 'accounts#show', as: :short_account_media
get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag
get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status end
get '/interact/:id', to: 'remote_interaction#new', as: :remote_interaction constraints(account_username: /[^@\/.]+/) do
post '/interact/:id', to: 'remote_interaction#create' 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') get '/settings', to: redirect('/settings/profile')
namespace :settings do namespace :settings do
@ -187,9 +214,6 @@ Rails.application.routes.draw do
resource :relationships, only: [:show, :update] resource :relationships, only: [:show, :update]
resource :statuses_cleanup, controller: :statuses_cleanup, 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 get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
resource :authorize_interaction, only: [:show, :create] resource :authorize_interaction, only: [:show, :create]
@ -642,8 +666,11 @@ Rails.application.routes.draw do
end end
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', to: 'about#show'
get '/about/more', to: redirect('/about') get '/about/more', to: redirect('/about')

View File

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

View File

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

View File

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

View File

@ -99,100 +99,6 @@ RSpec.describe AccountsController, type: :controller do
end end
it_behaves_like 'common response characteristics' 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 end
context 'with replies' do context 'with replies' do
@ -202,38 +108,6 @@ RSpec.describe AccountsController, type: :controller do
end end
it_behaves_like 'common response characteristics' 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 end
context 'with media' do context 'with media' do
@ -243,38 +117,6 @@ RSpec.describe AccountsController, type: :controller do
end end
it_behaves_like 'common response characteristics' 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 end
context 'with tag' do context 'with tag' do
@ -289,42 +131,6 @@ RSpec.describe AccountsController, type: :controller do
end end
it_behaves_like 'common response characteristics' 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
end end

View File

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

View File

@ -34,27 +34,6 @@ describe FollowerAccountsController do
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end end
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 end
context 'when format is json' do context 'when format is json' do

View File

@ -34,27 +34,6 @@ describe FollowingAccountsController do
expect(response).to have_http_status(403) expect(response).to have_http_status(403)
end end
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 end
context 'when format is json' do context 'when format is json' do

View File

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

View File

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

View File

@ -10,16 +10,15 @@ RSpec.describe TagsController, type: :controller do
let!(:late) { Fabricate(:status, tags: [tag], text: 'late #test') } let!(:late) { Fabricate(:status, tags: [tag], text: 'late #test') }
context 'when tag exists' do 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 } 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
end end
context 'when tag does not exist' do 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' } get :show, params: { id: 'none' }
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
end end
end end

View File

@ -18,36 +18,16 @@ feature 'Profile' do
visit account_path('alice') visit account_path('alice')
is_expected.to have_title("alice (@alice@#{local_domain})") 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 end
scenario 'I can change my account' do scenario 'I can change my account' do
visit settings_profile_path visit settings_profile_path
fill_in 'Display name', with: 'Bob' fill_in 'Display name', with: 'Bob'
fill_in 'Bio', with: 'Bob is silent' fill_in 'Bio', with: 'Bob is silent'
first('.btn[type=submit]').click
first('button[type=submit]').click
is_expected.to have_content 'Changes successfully saved!' is_expected.to have_content 'Changes successfully saved!'
# View my own public profile and see the changes
click_link "Bob @bob@#{local_domain}"
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')
end end
end end

View File

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

View File

@ -3,17 +3,6 @@
require 'rails_helper' require 'rails_helper'
describe 'The account show page' do 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 it 'has valid opengraph tags' do
alice = Fabricate(:account, username: 'alice', display_name: 'Alice') alice = Fabricate(:account, username: 'alice', display_name: 'Alice')
_status = Fabricate(:status, account: alice, text: 'Hello World') _status = Fabricate(:status, account: alice, text: 'Hello World')
@ -33,8 +22,4 @@ describe 'The account show page' do
def head_section def head_section
Nokogiri::Slop(response.body).html.head Nokogiri::Slop(response.body).html.head
end end
def h_feed_entries
Nokogiri::HTML(response.body).search('.h-feed .h-entry')
end
end end

View File

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

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