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