Add profile directory to web UI (#11688)
* Add profile directory to web UI * Add a line of bio to the directorymaster
parent
7802ebd5f3
commit
cb447b28c4
@ -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 |
@ -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, |
||||
}); |
@ -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> |
||||
); |
||||
} |
||||
|
||||
} |
@ -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> |
||||
); |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue