Add profile directory to web UI (#11688)
* Add profile directory to web UI * Add a line of bio to the directory
This commit is contained in:
		
							
								
								
									
										30
									
								
								app/controllers/api/v1/directories_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/controllers/api/v1/directories_controller.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController
 | 
			
		||||
  before_action :require_enabled!
 | 
			
		||||
  before_action :set_instance_presenter
 | 
			
		||||
  before_action :set_tag, only: :show
 | 
			
		||||
  before_action :set_tags
 | 
			
		||||
  before_action :set_accounts
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
@@ -28,13 +27,10 @@ class DirectoriesController < ApplicationController
 | 
			
		||||
    @tag = Tag.discoverable.find_normalized!(params[:id])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def set_tags
 | 
			
		||||
    @tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  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(15).tap do |query|
 | 
			
		||||
      query.merge!(Account.tagged_with(@tag.id)) if @tag
 | 
			
		||||
      query.merge!(Account.not_excluded_by_account(current_account)) if current_account
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										61
									
								
								app/javascript/mastodon/actions/directory.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/javascript/mastodon/actions/directory.js
									
									
									
									
									
										Normal 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,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										35
									
								
								app/javascript/mastodon/components/radio_button.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/javascript/mastodon/components/radio_button.js
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -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='' className='parallax' />
 | 
			
		||||
        </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'>
 | 
			
		||||
          {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										171
									
								
								app/javascript/mastodon/features/directory/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								app/javascript/mastodon/features/directory/index.js
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -107,7 +107,7 @@ class GettingStarted extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
      if (profile_directory) {
 | 
			
		||||
        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;
 | 
			
		||||
@@ -120,7 +120,7 @@ class GettingStarted extends ImmutablePureComponent {
 | 
			
		||||
      height += 34;
 | 
			
		||||
    } else if (profile_directory) {
 | 
			
		||||
      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;
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  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 { value: tag.name, label: `#${tag.name}` };
 | 
			
		||||
      });
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,18 @@ import BundleContainer from '../containers/bundle_container';
 | 
			
		||||
import ColumnLoading from './column_loading';
 | 
			
		||||
import DrawerLoading from './drawer_loading';
 | 
			
		||||
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 ComposePanel from './compose_panel';
 | 
			
		||||
import NavigationPanel from './navigation_panel';
 | 
			
		||||
@@ -30,6 +41,7 @@ const componentMap = {
 | 
			
		||||
  'DIRECT': DirectTimeline,
 | 
			
		||||
  'FAVOURITES': FavouritedStatuses,
 | 
			
		||||
  'LIST': ListTimeline,
 | 
			
		||||
  'DIRECTORY': Directory,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
 
 | 
			
		||||
@@ -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='/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>
 | 
			
		||||
    {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 />
 | 
			
		||||
 | 
			
		||||
@@ -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='/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 && <TrendsContainer />}
 | 
			
		||||
 
 | 
			
		||||
@@ -47,6 +47,7 @@ import {
 | 
			
		||||
  PinnedStatuses,
 | 
			
		||||
  Lists,
 | 
			
		||||
  Search,
 | 
			
		||||
  Directory,
 | 
			
		||||
} from './util/async-components';
 | 
			
		||||
import { me, forceSingleColumn } from '../../initial_state';
 | 
			
		||||
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='/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/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 | 
			
		||||
 
 | 
			
		||||
@@ -141,3 +141,7 @@ export function Tesseract () {
 | 
			
		||||
export function Audio () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/audio" */'../../audio');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Directory () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/directory" */'../../directory');
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,14 @@ import {
 | 
			
		||||
  MUTES_FETCH_SUCCESS,
 | 
			
		||||
  MUTES_EXPAND_SUCCESS,
 | 
			
		||||
} 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';
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
  case MUTES_EXPAND_SUCCESS:
 | 
			
		||||
    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:
 | 
			
		||||
    return state;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -2092,13 +2092,23 @@ a.account__display-name {
 | 
			
		||||
    padding: 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  //.column {
 | 
			
		||||
  //  margin-top: 0;
 | 
			
		||||
  .directory__list {
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-gap: 10px;
 | 
			
		||||
    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
 | 
			
		||||
 | 
			
		||||
  //  @media screen and (min-width: $no-gap-breakpoint) {
 | 
			
		||||
  //    margin-top: 10px;
 | 
			
		||||
  //  }
 | 
			
		||||
  //}
 | 
			
		||||
    @media screen and (max-width: $no-gap-breakpoint) {
 | 
			
		||||
      display: block;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .directory__card {
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .filter-form {
 | 
			
		||||
    display: flex;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .autosuggest-textarea__textarea {
 | 
			
		||||
    font-size: 16px;
 | 
			
		||||
@@ -4982,59 +4992,6 @@ a.status-card.compact:hover {
 | 
			
		||||
}
 | 
			
		||||
/* 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,
 | 
			
		||||
.fullscreen {
 | 
			
		||||
  .video-player__volume__current,
 | 
			
		||||
@@ -5387,28 +5344,130 @@ a.status-card.compact:hover {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.media-spoiler-video {
 | 
			
		||||
  background-size: cover;
 | 
			
		||||
  background-repeat: no-repeat;
 | 
			
		||||
  background-position: center;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  margin-top: 8px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  border: 0;
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
.directory {
 | 
			
		||||
  &__list {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    margin: 10px 0;
 | 
			
		||||
    transition: opacity 100ms ease-in;
 | 
			
		||||
 | 
			
		||||
.media-spoiler-video-play-icon {
 | 
			
		||||
  border-radius: 100px;
 | 
			
		||||
  color: rgba($primary-text-color, 0.8);
 | 
			
		||||
  font-size: 36px;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  padding: 5px;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
    &.loading {
 | 
			
		||||
      opacity: 0.7;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @media screen and (max-width: $no-gap-breakpoint) {
 | 
			
		||||
      margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__card {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    margin-bottom: 10px;
 | 
			
		||||
 | 
			
		||||
    &__img {
 | 
			
		||||
      height: 125px;
 | 
			
		||||
      position: relative;
 | 
			
		||||
      background: darken($ui-base-color, 12%);
 | 
			
		||||
 | 
			
		||||
      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;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &__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%;
 | 
			
		||||
        white-space: nowrap;
 | 
			
		||||
        overflow: hidden;
 | 
			
		||||
        text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
        p {
 | 
			
		||||
          display: none;
 | 
			
		||||
 | 
			
		||||
          &:first-child {
 | 
			
		||||
            display: inline;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
/* End Video Player */
 | 
			
		||||
 | 
			
		||||
.account-gallery__container {
 | 
			
		||||
  display: flex;
 | 
			
		||||
@@ -5484,6 +5543,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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 | 
			
		||||
    focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
 | 
			
		||||
    identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
 | 
			
		||||
    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
 | 
			
		||||
    discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
 | 
			
		||||
  }.freeze
 | 
			
		||||
 | 
			
		||||
  def self.default_key_transform
 | 
			
		||||
 
 | 
			
		||||
@@ -51,7 +51,6 @@
 | 
			
		||||
class Account < ApplicationRecord
 | 
			
		||||
  USERNAME_RE = /[a-z0-9_]+([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 AccountAvatar
 | 
			
		||||
@@ -100,11 +99,13 @@ class Account < ApplicationRecord
 | 
			
		||||
  scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
 | 
			
		||||
  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
 | 
			
		||||
  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 :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 :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,
 | 
			
		||||
           :unconfirmed_email,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,12 +6,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
 | 
			
		||||
  context :security
 | 
			
		||||
 | 
			
		||||
  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,
 | 
			
		||||
             :inbox, :outbox, :featured,
 | 
			
		||||
             :preferred_username, :name, :summary,
 | 
			
		||||
             :url, :manually_approves_followers
 | 
			
		||||
             :url, :manually_approves_followers,
 | 
			
		||||
             :discoverable
 | 
			
		||||
 | 
			
		||||
  has_one :public_key, serializer: ActivityPub::PublicKeySerializer
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
 | 
			
		||||
 | 
			
		||||
  attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
 | 
			
		||||
             :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_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
			
		||||
 
 | 
			
		||||
@@ -83,6 +83,7 @@ class ActivityPub::ProcessAccountService < BaseService
 | 
			
		||||
    @account.fields                  = property_values || {}
 | 
			
		||||
    @account.also_known_as           = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
 | 
			
		||||
    @account.actor_type              = actor_type
 | 
			
		||||
    @account.discoverable            = @json['discoverable'] || false
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def set_fetchable_attributes!
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
        = image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
 | 
			
		||||
 | 
			
		||||
      .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
 | 
			
		||||
          %strong.emojify.p-name= display_name(account, custom_emojify: true)
 | 
			
		||||
        %span
 | 
			
		||||
 
 | 
			
		||||
@@ -14,58 +14,10 @@
 | 
			
		||||
  %h1= t('directories.explore_mastodon', title: site_title)
 | 
			
		||||
  %p= t('directories.explanation')
 | 
			
		||||
 | 
			
		||||
.grid
 | 
			
		||||
  .column-0
 | 
			
		||||
    - if @accounts.empty?
 | 
			
		||||
      = nothing_here
 | 
			
		||||
    - else
 | 
			
		||||
      .directory
 | 
			
		||||
        %table.accounts-table
 | 
			
		||||
          %tbody
 | 
			
		||||
            - @accounts.each do |account|
 | 
			
		||||
              %tr
 | 
			
		||||
                %td= account_link_to account
 | 
			
		||||
                %td.accounts-table__count.optional
 | 
			
		||||
                  = number_to_human account.statuses_count, strip_insignificant_zeros: true
 | 
			
		||||
                  %small= t('accounts.posts', count: account.statuses_count).downcase
 | 
			
		||||
                %td.accounts-table__count.optional
 | 
			
		||||
                  = 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')
 | 
			
		||||
- if @accounts.empty?
 | 
			
		||||
  = nothing_here
 | 
			
		||||
- else
 | 
			
		||||
  .card-grid
 | 
			
		||||
    = render partial: 'application/card', collection: @accounts, as: :account
 | 
			
		||||
 | 
			
		||||
      = paginate @accounts
 | 
			
		||||
 | 
			
		||||
  .column-1
 | 
			
		||||
    - if user_signed_in?
 | 
			
		||||
      .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
 | 
			
		||||
            = t('settings.edit_profile')
 | 
			
		||||
            = fa_icon 'chevron-right fw'
 | 
			
		||||
 | 
			
		||||
    - if @tags.empty? && !user_signed_in?
 | 
			
		||||
      .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
 | 
			
		||||
              - 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'
 | 
			
		||||
  = paginate @accounts
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,7 @@
 | 
			
		||||
 | 
			
		||||
  - if Setting.profile_directory
 | 
			
		||||
    .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/
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -630,14 +630,8 @@ en:
 | 
			
		||||
    warning_title: Disseminated content availability
 | 
			
		||||
  directories:
 | 
			
		||||
    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
 | 
			
		||||
    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:
 | 
			
		||||
    blocked_domains: List of limited and blocked domains
 | 
			
		||||
    description: This is the list of servers that %{instance} limits or reject federation with.
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ en:
 | 
			
		||||
        bot: This account mainly performs automated actions and might not be monitored
 | 
			
		||||
        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
 | 
			
		||||
        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
 | 
			
		||||
        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
 | 
			
		||||
 
 | 
			
		||||
@@ -325,6 +325,7 @@ Rails.application.routes.draw do
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      resource :domain_blocks, only: [:show, :create, :destroy]
 | 
			
		||||
      resource :directory, only: [:show]
 | 
			
		||||
 | 
			
		||||
      resources :follow_requests, only: [:index] do
 | 
			
		||||
        member do
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user