Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- app/views/directories/index.html.haml
  Upstream has redesigned the profile directory, and we
  had a glitch-soc-specific change to hide follower counts.
  Ported that change to the new design.
lolsob-rspec
Thibaut Girka 2019-08-30 13:38:41 +02:00
commit 741b53651e
52 changed files with 900 additions and 205 deletions

View File

@ -94,7 +94,7 @@ gem 'tzinfo-data', '~> 1.2019'
gem 'webpacker', '~> 4.0' gem 'webpacker', '~> 4.0'
gem 'webpush' gem 'webpush'
gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a5733308af827e8491d284dbafa9128d7a2' gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d'
gem 'json-ld-preloaded', '~> 3.0' gem 'json-ld-preloaded', '~> 3.0'
gem 'rdf-normalize', '~> 0.3' gem 'rdf-normalize', '~> 0.3'

View File

@ -7,8 +7,8 @@ GIT
GIT GIT
remote: https://github.com/ruby-rdf/json-ld.git remote: https://github.com/ruby-rdf/json-ld.git
revision: 345b7a5733308af827e8491d284dbafa9128d7a2 revision: e742697a0906e74e8bb777ef98137bc3955d981d
ref: 345b7a5733308af827e8491d284dbafa9128d7a2 ref: e742697a0906e74e8bb777ef98137bc3955d981d
specs: specs:
json-ld (3.0.2) json-ld (3.0.2)
htmlentities (~> 4.3) htmlentities (~> 4.3)

View File

@ -36,6 +36,14 @@ class Api::BaseController < ApplicationController
render json: { error: 'This action is not allowed' }, status: 403 render json: { error: 'This action is not allowed' }, status: 403
end end
rescue_from Mastodon::RaceConditionError do
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from ActionController::ParameterMissing do |e|
render json: { error: e.to_s }, status: 400
end
def doorkeeper_unauthorized_render_options(error: nil) def doorkeeper_unauthorized_render_options(error: nil)
{ json: { error: (error.try(:description) || 'Not authorized') } } { json: { error: (error.try(:description) || 'Not authorized') } }
end end

View File

@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def account_statuses def account_statuses
statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
statuses = statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
statuses.merge!(only_media_scope) if truthy_param?(:only_media) statuses.merge!(only_media_scope) if truthy_param?(:only_media)
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present? statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
end end
def permitted_account_statuses def permitted_account_statuses

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class Api::V1::DirectoriesController < Api::BaseController
before_action :require_enabled!
before_action :set_accounts
def show
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def require_enabled!
return not_found unless Setting.profile_directory
end
def set_accounts
@accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT))
end
def accounts_scope
Account.discoverable.tap do |scope|
scope.merge!(Account.local) if truthy_param?(:local)
scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active'
scope.merge!(Account.order(id: :desc)) if params[:order] == 'new'
scope.merge!(Account.not_excluded_by_account(current_account)) if current_account
scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local)
end
end
end

View File

@ -22,11 +22,13 @@ class ApplicationController < ActionController::Base
helper_method :whitelist_mode? helper_method :whitelist_mode?
rescue_from ActionController::RoutingError, with: :not_found rescue_from ActionController::RoutingError, with: :not_found
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
rescue_from ActionController::UnknownFormat, with: :not_acceptable rescue_from ActionController::UnknownFormat, with: :not_acceptable
rescue_from ActionController::ParameterMissing, with: :bad_request
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from Mastodon::NotPermittedError, with: :forbidden
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
rescue_from Mastodon::RaceConditionError, with: :service_unavailable
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
before_action :require_functional!, if: :user_signed_in? before_action :require_functional!, if: :user_signed_in?
@ -166,10 +168,18 @@ class ApplicationController < ActionController::Base
respond_with_error(406) respond_with_error(406)
end end
def bad_request
respond_with_error(400)
end
def internal_server_error def internal_server_error
respond_with_error(500) respond_with_error(500)
end end
def service_unavailable
respond_with_error(503)
end
def single_user_mode? def single_user_mode?
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
end end

View File

@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController
before_action :require_enabled! before_action :require_enabled!
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_tag, only: :show before_action :set_tag, only: :show
before_action :set_tags
before_action :set_accounts before_action :set_accounts
before_action :set_pack before_action :set_pack
@ -33,13 +32,10 @@ class DirectoriesController < ApplicationController
@tag = Tag.discoverable.find_normalized!(params[:id]) @tag = Tag.discoverable.find_normalized!(params[:id])
end end
def set_tags
@tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? }
end
def set_accounts def set_accounts
@accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query| @accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query|
query.merge!(Account.tagged_with(@tag.id)) if @tag query.merge!(Account.tagged_with(@tag.id)) if @tag
query.merge!(Account.not_excluded_by_account(current_account)) if current_account
end end
end end

View File

@ -30,7 +30,7 @@ class RemoteFollowController < ApplicationController
end end
def session_params def session_params
{ acct: session[:remote_follow] } { acct: session[:remote_follow] || current_account&.username }
end end
def set_pack def set_pack

View File

@ -33,7 +33,7 @@ class RemoteInteractionController < ApplicationController
end end
def session_params def session_params
{ acct: session[:remote_follow] } { acct: session[:remote_follow] || current_account&.username }
end end
def set_status def set_status

View File

@ -11,7 +11,7 @@ module WellKnown
expires_in 3.days, public: true expires_in 3.days, public: true
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound, ActionController::ParameterMissing
head 404 head 404
end end

View File

@ -34,6 +34,26 @@ module StatusesHelper
end end
end end
def minimal_account_action_button(account)
if user_signed_in?
return if account.id == current_user.account_id
if current_account.following?(account) || current_account.requested?(account)
link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
fa_icon('user-times fw')
end
elsif !(account.memorial? || account.moved?)
link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
fa_icon('user-plus fw')
end
end
elsif !(account.memorial? || account.moved?)
link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
fa_icon('user-plus fw')
end
end
end
def svg_logo def svg_logo
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
end end

View File

@ -0,0 +1,61 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
import { fetchRelationships } from './accounts';
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL';
export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL';
export const fetchDirectory = params => (dispatch, getState) => {
dispatch(fetchDirectoryRequest());
api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchDirectorySuccess(data));
dispatch(fetchRelationships(data.map(x => x.id)));
}).catch(error => dispatch(fetchDirectoryFail(error)));
};
export const fetchDirectoryRequest = () => ({
type: DIRECTORY_FETCH_REQUEST,
});
export const fetchDirectorySuccess = accounts => ({
type: DIRECTORY_FETCH_SUCCESS,
accounts,
});
export const fetchDirectoryFail = error => ({
type: DIRECTORY_FETCH_FAIL,
error,
});
export const expandDirectory = params => (dispatch, getState) => {
dispatch(expandDirectoryRequest());
const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(expandDirectorySuccess(data));
dispatch(fetchRelationships(data.map(x => x.id)));
}).catch(error => dispatch(expandDirectoryFail(error)));
};
export const expandDirectoryRequest = () => ({
type: DIRECTORY_EXPAND_REQUEST,
});
export const expandDirectorySuccess = accounts => ({
type: DIRECTORY_EXPAND_SUCCESS,
accounts,
});
export const expandDirectoryFail = error => ({
type: DIRECTORY_EXPAND_FAIL,
error,
});

View File

@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default class RadioButton extends React.PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
checked: PropTypes.bool,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
label: PropTypes.node.isRequired,
};
render () {
const { name, value, checked, onChange, label } = this.props;
return (
<label className='radio-button'>
<input
name={name}
type='radio'
value={value}
checked={checked}
onChange={onChange}
/>
<span className={classNames('radio-button__input', { checked })} />
<span>{label}</span>
</label>
);
}
}

View File

@ -0,0 +1,149 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { makeGetAccount } from 'mastodon/selectors';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import Permalink from 'mastodon/components/permalink';
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import IconButton from 'mastodon/components/icon_button';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
import { shortNumberFormat } from 'mastodon/utils/numbers';
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { id }) => ({
account: getAccount(state, id),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
}));
} else {
dispatch(unfollowAccount(account.get('id')));
}
} else {
dispatch(followAccount(account.get('id')));
}
},
onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(blockAccount(account.get('id')));
}
},
onMute (account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(initMuteModal(account));
}
},
});
export default @injectIntl
@connect(makeMapStateToProps, mapDispatchToProps)
class AccountCard extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
};
handleFollow = () => {
this.props.onFollow(this.props.account);
}
handleBlock = () => {
this.props.onBlock(this.props.account);
}
handleMute = () => {
this.props.onMute(this.props.account);
}
render () {
const { account, intl } = this.props;
let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
} else if (blocking) {
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
} else if (muting) {
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
} else if (!account.get('moved') || following) {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
}
}
return (
<div className='directory__card'>
<div className='directory__card__img'>
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
</div>
<div className='directory__card__bar'>
<Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<Avatar account={account} size={48} />
<DisplayName account={account} />
</Permalink>
<div className='directory__card__bar__relationship account__relationship'>
{buttons}
</div>
</div>
<div className='directory__card__extra'>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
</div>
<div className='directory__card__extra'>
<div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
<div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
<div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,171 @@
import React from 'react';
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
import { List as ImmutableList } from 'immutable';
import AccountCard from './components/account_card';
import RadioButton from 'mastodon/components/radio_button';
import classNames from 'classnames';
import LoadMore from 'mastodon/components/load_more';
import { ScrollContainer } from 'react-router-scroll-4';
const messages = defineMessages({
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
});
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
domain: state.getIn(['meta', 'domain']),
});
export default @connect(mapStateToProps)
@injectIntl
class Directory extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list.isRequired,
dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
domain: PropTypes.string.isRequired,
params: PropTypes.shape({
order: PropTypes.string,
local: PropTypes.bool,
}),
};
state = {
order: null,
local: null,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
}
}
getParams = (props, state) => ({
order: state.order === null ? (props.params.order || 'active') : state.order,
local: state.local === null ? (props.params.local || false) : state.local,
});
handleMove = dir => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchDirectory(this.getParams(this.props, this.state)));
}
componentDidUpdate (prevProps, prevState) {
const { dispatch } = this.props;
const paramsOld = this.getParams(prevProps, prevState);
const paramsNew = this.getParams(this.props, this.state);
if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
dispatch(fetchDirectory(paramsNew));
}
}
setRef = c => {
this.column = c;
}
handleChangeOrder = e => {
const { dispatch, columnId } = this.props;
if (columnId) {
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
} else {
this.setState({ order: e.target.value });
}
}
handleChangeLocal = e => {
const { dispatch, columnId } = this.props;
if (columnId) {
dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
} else {
this.setState({ local: e.target.value === '1' });
}
}
handleLoadMore = () => {
const { dispatch } = this.props;
dispatch(expandDirectory(this.getParams(this.props, this.state)));
}
render () {
const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
const { order, local } = this.getParams(this.props, this.state);
const pinned = !!columnId;
const scrollableArea = (
<div className='scrollable' style={{ background: 'transparent' }}>
<div className='filter-form'>
<div className='filter-form__column' role='group'>
<RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
<RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
</div>
<div className='filter-form__column' role='group'>
<RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
<RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
</div>
</div>
<div className={classNames('directory__list', { loading: isLoading })}>
{accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
</div>
<LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
</div>
);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='address-book-o'
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
</Column>
);
}
}

View File

@ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent {
if (profile_directory) { if (profile_directory) {
navItems.push( navItems.push(
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' /> <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
); );
height += 48; height += 48;
@ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent {
height += 34; height += 34;
} else if (profile_directory) { } else if (profile_directory) {
navItems.push( navItems.push(
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' /> <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
); );
height += 48; height += 48;

View File

@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
}, },
onLoad (value) { onLoad (value) {
return api().get('/api/v2/search', { params: { q: value } }).then(response => { return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
return (response.data.hashtags || []).map((tag) => { return (response.data.hashtags || []).map((tag) => {
return { value: tag.name, label: `#${tag.name}` }; return { value: tag.name, label: `#${tag.name}` };
}); });

View File

@ -84,28 +84,38 @@ const makeMapStateToProps = () => {
const getDescendantsIds = createSelector([ const getDescendantsIds = createSelector([
(_, { id }) => id, (_, { id }) => id,
state => state.getIn(['contexts', 'replies']), state => state.getIn(['contexts', 'replies']),
], (statusId, contextReplies) => { state => state.get('statuses'),
let descendantsIds = Immutable.List(); ], (statusId, contextReplies, statuses) => {
descendantsIds = descendantsIds.withMutations(mutable => { let descendantsIds = [];
const ids = [statusId]; const ids = [statusId];
while (ids.length > 0) { while (ids.length > 0) {
let id = ids.shift(); let id = ids.shift();
const replies = contextReplies.get(id); const replies = contextReplies.get(id);
if (statusId !== id) { if (statusId !== id) {
mutable.push(id); descendantsIds.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
} }
});
return descendantsIds; if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
if (insertAt !== -1) {
descendantsIds.forEach((id, idx) => {
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
descendantsIds.splice(idx, 1);
descendantsIds.splice(insertAt, 0, id);
insertAt += 1;
}
});
}
return Immutable.List(descendantsIds);
}); });
const mapStateToProps = (state, props) => { const mapStateToProps = (state, props) => {

View File

@ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading'; import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading'; import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error'; import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components'; import {
Compose,
Notifications,
HomeTimeline,
CommunityTimeline,
PublicTimeline,
HashtagTimeline,
DirectTimeline,
FavouritedStatuses,
ListTimeline,
Directory,
} from '../../ui/util/async-components';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import ComposePanel from './compose_panel'; import ComposePanel from './compose_panel';
import NavigationPanel from './navigation_panel'; import NavigationPanel from './navigation_panel';
@ -30,6 +41,7 @@ const componentMap = {
'DIRECT': DirectTimeline, 'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses, 'FAVOURITES': FavouritedStatuses,
'LIST': ListTimeline, 'LIST': ListTimeline,
'DIRECTORY': Directory,
}; };
const messages = defineMessages({ const messages = defineMessages({

View File

@ -18,6 +18,7 @@ const NavigationPanel = () => (
<NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink> <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink> <NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink> <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
{profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.profile_directory' defaultMessage='Profile directory' /></NavLink>}
<ListPanel /> <ListPanel />
@ -25,7 +26,6 @@ const NavigationPanel = () => (
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a> <a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a> <a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
{!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>}
{showTrends && <div className='flex-spacer' />} {showTrends && <div className='flex-spacer' />}
{showTrends && <TrendsContainer />} {showTrends && <TrendsContainer />}

View File

@ -47,6 +47,7 @@ import {
PinnedStatuses, PinnedStatuses,
Lists, Lists,
Search, Search,
Directory,
} from './util/async-components'; } from './util/async-components';
import { me, forceSingleColumn } from '../../initial_state'; import { me, forceSingleColumn } from '../../initial_state';
import { previewState as previewMediaState } from './components/media_modal'; import { previewState as previewMediaState } from './components/media_modal';
@ -188,6 +189,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
<WrappedRoute path='/search' component={Search} content={children} /> <WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
<WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />

View File

@ -141,3 +141,7 @@ export function Tesseract () {
export function Audio () { export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio'); return import(/* webpackChunkName: "features/audio" */'../../audio');
} }
export function Directory () {
return import(/* webpackChunkName: "features/directory" */'../../directory');
}

View File

@ -20,6 +20,14 @@ import {
MUTES_FETCH_SUCCESS, MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS, MUTES_EXPAND_SUCCESS,
} from '../actions/mutes'; } from '../actions/mutes';
import {
DIRECTORY_FETCH_REQUEST,
DIRECTORY_FETCH_SUCCESS,
DIRECTORY_FETCH_FAIL,
DIRECTORY_EXPAND_REQUEST,
DIRECTORY_EXPAND_SUCCESS,
DIRECTORY_EXPAND_FAIL,
} from 'mastodon/actions/directory';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) {
return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case MUTES_EXPAND_SUCCESS: case MUTES_EXPAND_SUCCESS:
return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case DIRECTORY_FETCH_SUCCESS:
return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
case DIRECTORY_EXPAND_SUCCESS:
return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
case DIRECTORY_FETCH_REQUEST:
case DIRECTORY_EXPAND_REQUEST:
return state.setIn(['directory', 'isLoading'], true);
case DIRECTORY_FETCH_FAIL:
case DIRECTORY_EXPAND_FAIL:
return state.setIn(['directory', 'isLoading'], false);
default: default:
return state; return state;
} }

View File

@ -2092,13 +2092,23 @@ a.account__display-name {
padding: 0; padding: 0;
} }
//.column { .directory__list {
// margin-top: 0; display: grid;
grid-gap: 10px;
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
// @media screen and (min-width: $no-gap-breakpoint) { @media screen and (max-width: $no-gap-breakpoint) {
// margin-top: 10px; display: block;
// } }
//} }
.directory__card {
margin-bottom: 0;
}
.filter-form {
display: flex;
}
.autosuggest-textarea__textarea { .autosuggest-textarea__textarea {
font-size: 16px; font-size: 16px;
@ -4982,59 +4992,6 @@ a.status-card.compact:hover {
} }
/* End Media Gallery */ /* End Media Gallery */
/* Status Video Player */
.status__video-player {
background: $base-overlay-background;
box-sizing: border-box;
cursor: default; /* May not be needed */
margin-top: 8px;
overflow: hidden;
position: relative;
}
.status__video-player-video {
height: 100%;
object-fit: cover;
position: relative;
top: 50%;
transform: translateY(-50%);
width: 100%;
z-index: 1;
}
.status__video-player-expand,
.status__video-player-mute {
color: $primary-text-color;
opacity: 0.8;
position: absolute;
right: 4px;
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
}
.status__video-player-spoiler {
display: none;
color: $primary-text-color;
left: 4px;
position: absolute;
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
top: 4px;
z-index: 100;
&.status__video-player-spoiler--visible {
display: block;
}
}
.status__video-player-expand {
bottom: 4px;
z-index: 100;
}
.status__video-player-mute {
top: 4px;
z-index: 5;
}
.detailed, .detailed,
.fullscreen { .fullscreen {
.video-player__volume__current, .video-player__volume__current,
@ -5387,28 +5344,137 @@ a.status-card.compact:hover {
} }
} }
.media-spoiler-video { .directory {
background-size: cover; &__list {
background-repeat: no-repeat; width: 100%;
background-position: center; margin: 10px 0;
cursor: pointer; transition: opacity 100ms ease-in;
margin-top: 8px;
position: relative;
border: 0;
display: block;
}
.media-spoiler-video-play-icon { &.loading {
border-radius: 100px; opacity: 0.7;
color: rgba($primary-text-color, 0.8); }
font-size: 36px;
left: 50%; @media screen and (max-width: $no-gap-breakpoint) {
padding: 5px; margin: 0;
position: absolute; }
top: 50%; }
transform: translate(-50%, -50%);
&__card {
box-sizing: border-box;
margin-bottom: 10px;
&__img {
height: 125px;
position: relative;
background: darken($ui-base-color, 12%);
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
}
}
&__bar {
display: flex;
align-items: center;
background: lighten($ui-base-color, 4%);
padding: 10px;
&__name {
flex: 1 1 auto;
display: flex;
align-items: center;
text-decoration: none;
overflow: hidden;
}
&__relationship {
width: 23px;
min-height: 1px;
flex: 0 0 auto;
}
.avatar {
flex: 0 0 auto;
width: 48px;
height: 48px;
padding-top: 2px;
img {
width: 100%;
height: 100%;
display: block;
margin: 0;
border-radius: 4px;
background: darken($ui-base-color, 8%);
object-fit: cover;
}
}
.display-name {
margin-left: 15px;
text-align: left;
strong {
font-size: 15px;
color: $primary-text-color;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
span {
display: block;
font-size: 14px;
color: $darker-text-color;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
&__extra {
background: $ui-base-color;
display: flex;
align-items: center;
justify-content: center;
.accounts-table__count {
width: 33.33%;
flex: 0 0 auto;
padding: 15px 0;
}
.account__header__content {
box-sizing: border-box;
padding: 15px 10px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
width: 100%;
min-height: 18px + 30px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
p {
display: none;
&:first-child {
display: inline;
}
}
br {
display: none;
}
}
}
}
} }
/* End Video Player */
.account-gallery__container { .account-gallery__container {
display: flex; display: flex;
@ -5484,6 +5550,73 @@ a.status-card.compact:hover {
} }
} }
} }
&.directory__section-headline {
background: darken($ui-base-color, 2%);
border-bottom-color: transparent;
a,
button {
&.active {
&::before {
display: none;
}
&::after {
border-color: transparent transparent darken($ui-base-color, 7%);
}
}
}
}
}
.filter-form {
background: $ui-base-color;
&__column {
padding: 10px 15px;
}
.radio-button {
display: block;
}
}
.radio-button {
font-size: 14px;
position: relative;
display: inline-block;
padding: 6px 0;
line-height: 18px;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
input[type=radio],
input[type=checkbox] {
display: none;
}
&__input {
display: inline-block;
position: relative;
border: 1px solid $ui-primary-color;
box-sizing: border-box;
width: 18px;
height: 18px;
flex: 0 0 auto;
margin-right: 10px;
top: -1px;
border-radius: 50%;
vertical-align: middle;
&.checked {
border-color: lighten($ui-highlight-color, 8%);
background: lighten($ui-highlight-color, 8%);
}
}
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {

View File

@ -763,6 +763,24 @@
} }
} }
.directory__list {
display: grid;
grid-gap: 10px;
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
@media screen and (max-width: $no-gap-breakpoint) {
display: block;
}
.icon-button {
font-size: 18px;
}
}
.directory__card {
margin-bottom: 0;
}
.card-grid { .card-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -20,6 +20,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } }, focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' }, identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
}.freeze }.freeze
def self.default_key_transform def self.default_key_transform

View File

@ -51,7 +51,6 @@
class Account < ApplicationRecord class Account < ApplicationRecord
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
MIN_FOLLOWERS_DISCOVERY = 10
include AccountAssociations include AccountAssociations
include AccountAvatar include AccountAvatar
@ -104,11 +103,13 @@ class Account < ApplicationRecord
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) } scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) } scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) } scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) } scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
scope :popular, -> { order('account_stats.followers_count desc') } scope :popular, -> { order('account_stats.followers_count desc') }
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
delegate :email, delegate :email,
:unconfirmed_email, :unconfirmed_email,

View File

@ -9,6 +9,11 @@ class Feed
end end
def get(limit, max_id = nil, since_id = nil, min_id = nil) def get(limit, max_id = nil, since_id = nil, min_id = nil)
limit = limit.to_i
max_id = max_id.to_i if max_id.present?
since_id = since_id.to_i if since_id.present?
min_id = min_id.to_i if min_id.present?
from_redis(limit, max_id, since_id, min_id) from_redis(limit, max_id, since_id, min_id)
end end

View File

@ -28,12 +28,12 @@ class MediaAttachment < ApplicationRecord
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp).freeze AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/3gpp).freeze AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
BLURHASH_OPTIONS = { BLURHASH_OPTIONS = {
x_comp: 4, x_comp: 4,

View File

@ -6,7 +6,7 @@ class RemoteFollow
attr_accessor :acct, :addressable_template attr_accessor :acct, :addressable_template
validates :acct, presence: true validates :acct, presence: true, domain: { acct: true }
def initialize(attrs = {}) def initialize(attrs = {})
@acct = normalize_acct(attrs[:acct]) @acct = normalize_acct(attrs[:acct])
@ -21,7 +21,7 @@ class RemoteFollow
end end
def subscribe_address_for(account) def subscribe_address_for(account)
addressable_template.expand(uri: account.local_username_and_domain).to_s addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(account)).to_s
end end
def interact_address_for(status) def interact_address_for(status)
@ -44,6 +44,8 @@ class RemoteFollow
end end
[username, domain].compact.join('@') [username, domain].compact.join('@')
rescue Addressable::URI::InvalidURIError
value
end end
def fetch_template! def fetch_template!

View File

@ -6,12 +6,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context :security context :security
context_extensions :manually_approves_followers, :featured, :also_known_as, context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :hashtag, :emoji, :identity_proof :moved_to, :property_value, :hashtag, :emoji, :identity_proof,
:discoverable
attributes :id, :type, :following, :followers, attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured, :inbox, :outbox, :featured,
:preferred_username, :name, :summary, :preferred_username, :name, :summary,
:url, :manually_approves_followers :url, :manually_approves_followers,
:discoverable
has_one :public_key, serializer: ActivityPub::PublicKeySerializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer

View File

@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at, attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
:note, :url, :avatar, :avatar_static, :header, :header_static, :note, :url, :avatar, :avatar_static, :header, :header_static,
:followers_count, :following_count, :statuses_count :followers_count, :following_count, :statuses_count, :last_status_at
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
has_many :emojis, serializer: REST::CustomEmojiSerializer has_many :emojis, serializer: REST::CustomEmojiSerializer

View File

@ -83,6 +83,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.fields = property_values || {} @account.fields = property_values || {}
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) } @account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
@account.actor_type = actor_type @account.actor_type = actor_type
@account.discoverable = @json['discoverable'] || false
end end
def set_fetchable_attributes! def set_fetchable_attributes!

View File

@ -4,14 +4,22 @@ class DomainValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value) def validate_each(record, attribute, value)
return if value.blank? return if value.blank?
record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(value) domain = begin
if options[:acct]
value.split('@').last
else
value
end
end
record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(domain)
end end
private private
def compliant?(value) def compliant?(value)
Addressable::URI.new.tap { |uri| uri.host = value } Addressable::URI.new.tap { |uri| uri.host = value }
rescue Addressable::URI::InvalidURIError rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
false false
end end
end end

View File

@ -14,6 +14,7 @@ class EmailMxValidator < ActiveModel::Validator
return true if domain.nil? return true if domain.nil?
domain = TagManager.instance.normalize_domain(domain)
hostnames = [] hostnames = []
ips = [] ips = []
@ -29,6 +30,8 @@ class EmailMxValidator < ActiveModel::Validator
end end
ips.empty? || on_blacklist?(hostnames + ips) ips.empty? || on_blacklist?(hostnames + ips)
rescue Addressable::URI::InvalidURIError
true
end end
def on_blacklist?(values) def on_blacklist?(values)

View File

@ -9,7 +9,7 @@
= image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo' = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
.display-name .display-name
%span{id: "default_account_display_name", style: "display:none;"}= account.username %span{ id: "default_account_display_name", style: "display: none" }= account.username
%bdi %bdi
%strong.emojify.p-name= display_name(account, custom_emojify: true) %strong.emojify.p-name= display_name(account, custom_emojify: true)
%span %span

View File

@ -14,58 +14,43 @@
%h1= t('directories.explore_mastodon', title: site_title) %h1= t('directories.explore_mastodon', title: site_title)
%p= t('directories.explanation') %p= t('directories.explanation')
.grid - if @accounts.empty?
.column-0 = nothing_here
- if @accounts.empty? - else
= nothing_here .directory__list
- else - @accounts.each do |account|
.directory .directory__card
%table.accounts-table .directory__card__img
%tbody = image_tag account.header.url, alt: ''
- @accounts.each do |account| .directory__card__bar
%tr = link_to TagManager.instance.url_for(account), class: 'directory__card__bar__name' do
%td= account_link_to account .avatar
%td.accounts-table__count.optional = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
= number_to_human account.statuses_count, strip_insignificant_zeros: true
%small= t('accounts.posts', count: account.statuses_count).downcase
%td.accounts-table__count.optional
= hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
%small= t('accounts.followers', count: account.followers_count).downcase
%td.accounts-table__count
- if account.last_status_at.present?
%time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
- else
\-
%small= t('accounts.last_active')
= paginate @accounts .display-name
%span{ id: "default_account_display_name", style: "display: none" }= account.username
%bdi
%strong.emojify.p-name= display_name(account, custom_emojify: true)
%span= acct(account)
.directory__card__bar__relationship.account__relationship
= minimal_account_action_button(account)
.column-1 .directory__card__extra
- if user_signed_in? .account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true)
.box-widget.notice-widget
- if current_account.discoverable?
- if current_account.followers_count < Account::MIN_FOLLOWERS_DISCOVERY
%p= t('directories.enabled_but_waiting', min_followers: Account::MIN_FOLLOWERS_DISCOVERY)
- else
%p= t('directories.enabled')
- else
%p= t('directories.how_to_enable')
= link_to settings_profile_path do .directory__card__extra
= t('settings.edit_profile') .accounts-table__count
= fa_icon 'chevron-right fw' = number_to_human account.statuses_count, strip_insignificant_zeros: true
%small= t('accounts.posts', count: account.statuses_count).downcase
.accounts-table__count
= hide_followers_count?(account) ? '-' : (number_to_human account.followers_count, strip_insignificant_zeros: true)
%small= t('accounts.followers', count: account.followers_count).downcase
.accounts-table__count
- if account.last_status_at.present?
%time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
- else
= t('invites.expires_in_prompt')
- if @tags.empty? && !user_signed_in? %small= t('accounts.last_active')
.nothing-here
- else
- @tags.each do |tag|
.directory__tag{ class: tag.id == @tag&.id ? 'active' : nil }
= link_to explore_hashtag_path(tag) do
%h4
= fa_icon 'hashtag'
= tag.name
%small= t('directories.people', count: tag.accounts_count)
.avatar-stack = paginate @accounts
- tag.cached_sample_accounts.each do |account|
= image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'

View File

@ -0,0 +1,5 @@
- content_for :page_title do
= t('errors.400')
- content_for :content do
= t('errors.400')

View File

@ -0,0 +1,5 @@
- content_for :page_title do
= t('errors.406')
- content_for :content do
= t('errors.406')

View File

@ -0,0 +1,5 @@
- content_for :page_title do
= t('errors.503')
- content_for :content do
= t('errors.503')

View File

@ -28,7 +28,7 @@
- if Setting.profile_directory - if Setting.profile_directory
.fields-group .fields-group
= f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable_html', min_followers: Account::MIN_FOLLOWERS_DISCOVERY, path: explore_path), recommended: true = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true
%hr.spacer/ %hr.spacer/

View File

@ -42,11 +42,11 @@
- unless @warning.text.blank? - unless @warning.text.blank?
= Formatter.instance.linkify(@warning.text) = Formatter.instance.linkify(@warning.text)
- unless @statuses.empty? - unless @statuses&.empty?
%p %p
%strong= t('user_mailer.warning.statuses') %strong= t('user_mailer.warning.statuses')
- unless @statuses.empty? - unless @statuses&.empty?
- @statuses.each_with_index do |status, i| - @statuses.each_with_index do |status, i|
= render 'notification_mailer/status', status: status, i: i + 1, highlighted: true = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true

View File

@ -7,7 +7,7 @@
<% end %> <% end %>
<%= @warning.text %> <%= @warning.text %>
<% unless @statuses.empty? %> <% unless @statuses&.empty? %>
<%= t('user_mailer.warning.statuses') %> <%= t('user_mailer.warning.statuses') %>
<% @statuses.each do |status| %> <% @statuses.each do |status| %>

View File

@ -643,14 +643,8 @@ en:
warning_title: Disseminated content availability warning_title: Disseminated content availability
directories: directories:
directory: Profile directory directory: Profile directory
enabled: You are currently listed in the directory.
enabled_but_waiting: You have opted-in to be listed in the directory, but you do not have the minimum number of followers (%{min_followers}) to be listed yet.
explanation: Discover users based on their interests explanation: Discover users based on their interests
explore_mastodon: Explore %{title} explore_mastodon: Explore %{title}
how_to_enable: You are not currently opted-in to the directory. You can opt-in below. Use hashtags in your bio text to be listed under specific hashtags!
people:
one: "%{count} person"
other: "%{count} people"
domain_blocks: domain_blocks:
blocked_domains: List of limited and blocked domains blocked_domains: List of limited and blocked domains
description: This is the list of servers that %{instance} limits or reject federation with. description: This is the list of servers that %{instance} limits or reject federation with.
@ -671,8 +665,10 @@ en:
domain_validator: domain_validator:
invalid_domain: is not a valid domain name invalid_domain: is not a valid domain name
errors: errors:
'400': The request you submitted was invalid or malformed.
'403': You don't have permission to view this page. '403': You don't have permission to view this page.
'404': The page you are looking for isn't here. '404': The page you are looking for isn't here.
'406': This page is not available in the requested format.
'410': The page you were looking for doesn't exist here anymore. '410': The page you were looking for doesn't exist here anymore.
'422': '422':
content: Security verification failed. Are you blocking cookies? content: Security verification failed. Are you blocking cookies?
@ -681,6 +677,7 @@ en:
'500': '500':
content: We're sorry, but something went wrong on our end. content: We're sorry, but something went wrong on our end.
title: This page is not correct title: This page is not correct
'503': The page could not be served due to a temporary server failure.
noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="%{apps_path}">native apps</a> for Mastodon for your platform. noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="%{apps_path}">native apps</a> for Mastodon for your platform.
existing_username_validator: existing_username_validator:
not_found: could not find a local user with that username not_found: could not find a local user with that username

View File

@ -16,7 +16,7 @@ en:
bot: This account mainly performs automated actions and might not be monitored bot: This account mainly performs automated actions and might not be monitored
context: One or multiple contexts where the filter should apply context: One or multiple contexts where the filter should apply
digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
discoverable_html: The <a href="%{path}" target="_blank">directory</a> lets people find accounts based on interests and activity. Requires at least %{min_followers} followers discoverable: The profile directory is another way by which your account can reach a wider audience
email: You will be sent a confirmation e-mail email: You will be sent a confirmation e-mail
fields: You can have up to 4 items displayed as a table on your profile fields: You can have up to 4 items displayed as a table on your profile
header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px

View File

@ -6,6 +6,8 @@ require 'sidekiq-scheduler/web'
Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base] Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base]
Rails.application.routes.draw do Rails.application.routes.draw do
root 'home#index'
mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development? mount LetterOpenerWeb::Engine, at: 'letter_opener' if Rails.env.development?
authenticate :user, lambda { |u| u.admin? } do authenticate :user, lambda { |u| u.admin? } do
@ -336,6 +338,7 @@ Rails.application.routes.draw do
end end
resource :domain_blocks, only: [:show, :create, :destroy] resource :domain_blocks, only: [:show, :create, :destroy]
resource :directory, only: [:show]
resources :follow_requests, only: [:index] do resources :follow_requests, only: [:index] do
member do member do
@ -440,10 +443,6 @@ Rails.application.routes.draw do
get '/about/blocks', to: 'about#blocks' get '/about/blocks', to: 'about#blocks'
get '/terms', to: 'about#terms' get '/terms', to: 'about#terms'
root 'home#index' match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false
match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false
match '*unmatched_route',
via: :all,
to: 'application#raise_not_found',
format: false
end end

2
dist/nginx.conf vendored
View File

@ -19,7 +19,7 @@ server {
listen [::]:443 ssl http2; listen [::]:443 ssl http2;
server_name example.com; server_name example.com;
ssl_protocols TLSv1.2; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA; ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m; ssl_session_cache shared:SSL:10m;

View File

@ -2,7 +2,7 @@
"name": "mastodon", "name": "mastodon",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"engines": { "engines": {
"node": ">=8.12 <12" "node": ">=8.12 <13"
}, },
"scripts": { "scripts": {
"postversion": "git push --tags", "postversion": "git push --tags",

View File

@ -66,9 +66,7 @@ describe RemoteFollowController do
end end
it 'redirects to the remote location' do it 'redirects to the remote location' do
address = "http://example.com/follow_me?acct=test_user%40#{Rails.configuration.x.local_domain}" expect(response).to redirect_to("http://example.com/follow_me?acct=https%3A%2F%2F#{Rails.configuration.x.local_domain}%2Fusers%2Ftest_user")
expect(response).to redirect_to(address)
end end
end end
end end

View File

@ -50,7 +50,8 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
describe 'when form_two_factor_confirmation parameter is not provided' do describe 'when form_two_factor_confirmation parameter is not provided' do
it 'raises ActionController::ParameterMissing' do it 'raises ActionController::ParameterMissing' do
expect { post :create, params: {} }.to raise_error(ActionController::ParameterMissing) post :create, params: {}
expect(response).to have_http_status(400)
end end
end end

View File

@ -112,7 +112,8 @@ describe Settings::TwoFactorAuthenticationsController do
end end
it 'raises ActionController::ParameterMissing if code is missing' do it 'raises ActionController::ParameterMissing if code is missing' do
expect { post :destroy }.to raise_error(ActionController::ParameterMissing) post :destroy
expect(response).to have_http_status(400)
end end
end end

View File

@ -61,7 +61,7 @@ RSpec.describe RemoteFollow do
subject { remote_follow.subscribe_address_for(account) } subject { remote_follow.subscribe_address_for(account) }
it 'returns subscribe address' do it 'returns subscribe address' do
is_expected.to eq 'https://quitter.no/main/ostatussub?profile=alice%40cb6e6126.ngrok.io' is_expected.to eq 'https://quitter.no/main/ostatussub?profile=https%3A%2F%2Fcb6e6126.ngrok.io%2Fusers%2Falice'
end end
end end
end end