* Add profile directory to web UI * Add a line of bio to the directorymaster^2
@@ -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 | |||
@@ -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> | |||
); | |||
} | |||
} |
@@ -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 | |||