Replacing follow requests in the settings area with in-UI column

rebase/4.0.0rc2
Eugen Rochko 2016-12-26 21:33:51 +01:00
parent 004382e4d0
commit 3689c119f0
17 changed files with 334 additions and 87 deletions

View File

@ -51,6 +51,22 @@ export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL';
export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export function setAccountSelf(account) {
return {
type: ACCOUNT_SET_SELF,
@ -509,3 +525,140 @@ export function fetchRelationshipsFail(error) {
error
};
};
export function fetchFollowRequests() {
return (dispatch, getState) => {
dispatch(fetchFollowRequestsRequest());
api(getState).get('/api/v1/follow_requests').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null))
}).catch(error => dispatch(fetchFollowRequestsFail(error)));
};
};
export function fetchFollowRequestsRequest() {
return {
type: FOLLOW_REQUESTS_FETCH_REQUEST
};
};
export function fetchFollowRequestsSuccess(accounts, next) {
return {
type: FOLLOW_REQUESTS_FETCH_SUCCESS,
accounts,
next
};
};
export function fetchFollowRequestsFail(error) {
return {
type: FOLLOW_REQUESTS_FETCH_FAIL,
error
};
};
export function expandFollowRequests() {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
if (url === null) {
return;
}
dispatch(expandFollowRequestsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null))
}).catch(error => dispatch(expandFollowRequestsFail(error)));
};
};
export function expandFollowRequestsRequest() {
return {
type: FOLLOW_REQUESTS_EXPAND_REQUEST
};
};
export function expandFollowRequestsSuccess(accounts, next) {
return {
type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
accounts,
next
};
};
export function expandFollowRequestsFail(error) {
return {
type: FOLLOW_REQUESTS_EXPAND_FAIL,
error
};
};
export function authorizeFollowRequest(id) {
return (dispatch, getState) => {
dispatch(authorizeFollowRequestRequest(id));
api(getState)
.post(`/api/v1/follow_requests/${id}/authorize`)
.then(response => dispatch(authorizeFollowRequestSuccess(id)))
.catch(error => dispatch(authorizeFollowRequestFail(id, error)));
};
};
export function authorizeFollowRequestRequest(id) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
id
};
};
export function authorizeFollowRequestSuccess(id) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
id
};
};
export function authorizeFollowRequestFail(id, error) {
return {
type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
id,
error
};
};
export function rejectFollowRequest(id) {
return (dispatch, getState) => {
dispatch(rejectFollowRequestRequest(id));
api(getState)
.post(`/api/v1/follow_requests/${id}/reject`)
.then(response => dispatch(rejectFollowRequestSuccess(id)))
.catch(error => dispatch(rejectFollowRequestFail(id, error)));
};
};
export function rejectFollowRequestRequest(id) {
return {
type: FOLLOW_REQUEST_REJECT_REQUEST,
id
};
};
export function rejectFollowRequestSuccess(id) {
return {
type: FOLLOW_REQUEST_REJECT_SUCCESS,
id
};
};
export function rejectFollowRequestFail(id, error) {
return {
type: FOLLOW_REQUEST_REJECT_FAIL,
id,
error
};
};

View File

@ -27,11 +27,11 @@ const StatusList = React.createClass({
this._oldScrollPosition = scrollHeight - scrollTop;
if (scrollTop === scrollHeight - clientHeight) {
if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
this.props.onScrollToBottom();
} else if (scrollTop < 100) {
} else if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
} else {
} else if (this.props.onScroll) {
this.props.onScroll();
}
},

View File

@ -34,6 +34,7 @@ import Reblogs from '../features/reblogs';
import Favourites from '../features/favourites';
import HashtagTimeline from '../features/hashtag_timeline';
import Notifications from '../features/notifications';
import FollowRequests from '../features/follow_requests';
import { IntlProvider, addLocaleData } from 'react-intl';
import en from 'react-intl/locale-data/en';
import de from 'react-intl/locale-data/de';
@ -125,6 +126,8 @@ const Mastodon = React.createClass({
<Route path='followers' component={Followers} />
<Route path='following' component={Following} />
</Route>
<Route path='follow_requests' component={FollowRequests} />
</Route>
</Router>
</Provider>

View File

@ -0,0 +1,61 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from '../../../components/permalink';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import emojify from '../../../emoji';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }
});
const outerStyle = {
padding: '14px 10px'
};
const panelStyle = {
background: '#2f3441',
display: 'flex',
flexDirection: 'row',
borderTop: '1px solid #363c4b',
borderBottom: '1px solid #363c4b',
padding: '10px 0'
};
const btnStyle = {
flex: '1 1 auto',
textAlign: 'center'
};
const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => {
const content = { __html: emojify(account.get('note')) };
return (
<div>
<div style={outerStyle}>
<Permalink href={account.get('url')} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={48} /></div>
<DisplayName account={account} />
</Permalink>
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
</div>
<div style={panelStyle}>
<div style={btnStyle}><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
<div style={btnStyle}><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
</div>
</div>
)
};
AccountAuthorize.propTypes = {
account: ImmutablePropTypes.map.isRequired,
onAuthorize: React.PropTypes.func.isRequired,
onReject: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
};
export default injectIntl(AccountAuthorize);

View File

@ -0,0 +1,26 @@
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import AccountAuthorize from '../components/account_authorize';
import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id)
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { id }) => ({
onAuthorize (account) {
dispatch(authorizeFollowRequest(id));
},
onReject (account) {
dispatch(rejectFollowRequest(id));
}
});
export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);

View File

@ -0,0 +1,66 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { ScrollContainer } from 'react-router-scroll';
import Column from '../ui/components/column';
import AccountAuthorizeContainer from './containers/account_authorize_container';
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }
});
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'follow_requests', 'items'])
});
const FollowRequests = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
intl: React.PropTypes.object.isRequired
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFollowRequests());
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowRequests());
}
},
render () {
const { intl, accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='users' heading={intl.formatMessage(messages.heading)}>
<ScrollContainer scrollKey='follow_requests'>
<div className='scrollable' onScroll={this.handleScroll}>
{accountIds.map(id =>
<AccountAuthorizeContainer key={id} id={id} />
)}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(injectIntl(FollowRequests));

View File

@ -7,7 +7,8 @@ import { connect } from 'react-redux';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' }
settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }
});
const mapStateToProps = state => ({
@ -32,6 +33,7 @@ const GettingStarted = ({ intl, me }) => {
<div style={hamburgerStyle}><i className='fa fa-bars' /></div>
<ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.settings)} href='/settings/profile' />
<ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />
</div>
<div className='static-content'>
@ -43,4 +45,9 @@ const GettingStarted = ({ intl, me }) => {
);
};
GettingStarted.propTypes = {
intl: React.PropTypes.object.isRequired,
me: React.PropTypes.number
};
export default connect(mapStateToProps)(injectIntl(GettingStarted));

View File

@ -6,7 +6,8 @@ import {
FOLLOWING_FETCH_SUCCESS,
FOLLOWING_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
ACCOUNT_TIMELINE_EXPAND_SUCCESS,
FOLLOW_REQUESTS_FETCH_SUCCESS
} from '../actions/accounts';
import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
import {
@ -78,6 +79,7 @@ export default function accounts(state = initialState, action) {
case FAVOURITES_FETCH_SUCCESS:
case COMPOSE_SUGGESTIONS_READY:
case SEARCH_SUGGESTIONS_READY:
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeAccounts(state, action.accounts);
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:

View File

@ -2,7 +2,10 @@ import {
FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_EXPAND_SUCCESS,
FOLLOWING_FETCH_SUCCESS,
FOLLOWING_EXPAND_SUCCESS
FOLLOWING_EXPAND_SUCCESS,
FOLLOW_REQUESTS_FETCH_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS
} from '../actions/accounts';
import {
REBLOGS_FETCH_SUCCESS,
@ -14,7 +17,8 @@ const initialState = Immutable.Map({
followers: Immutable.Map(),
following: Immutable.Map(),
reblogged_by: Immutable.Map(),
favourited_by: Immutable.Map()
favourited_by: Immutable.Map(),
follow_requests: Immutable.Map()
});
const normalizeList = (state, type, id, accounts, next) => {
@ -44,6 +48,11 @@ export default function userLists(state = initialState, action) {
return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
case FOLLOW_REQUEST_REJECT_SUCCESS:
return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
default:
return state;
}

View File

@ -1,28 +0,0 @@
# frozen_string_literal: true
class FollowRequestsController < ApplicationController
layout 'auth'
before_action :authenticate_user!
before_action :set_follow_request, except: :index
def index
@follow_requests = FollowRequest.where(target_account: current_account)
end
def authorize
@follow_request.authorize!
redirect_to follow_requests_path
end
def reject
@follow_request.reject!
redirect_to follow_requests_path
end
private
def set_follow_request
@follow_request = FollowRequest.find(params[:id])
end
end

View File

@ -1,2 +0,0 @@
module FollowRequestsHelper
end

View File

@ -1,16 +0,0 @@
- content_for :page_title do
= t('follow_requests.title')
- if @follow_requests.empty?
%p.nothing-here= t('accounts.nothing_here')
- else
%table.table
%tbody
- @follow_requests.each do |follow_request|
%tr
%td= link_to follow_request.account.acct, web_path("accounts/#{follow_request.account.id}")
%td{ style: 'text-align: right' }
= table_link_to 'check-circle', t('follow_requests.authorize'), authorize_follow_request_path(follow_request), method: :post
= table_link_to 'times-circle', t('follow_requests.reject'), reject_follow_request_path(follow_request), method: :post
.form-footer= render "settings/shared/links"

View File

@ -1,8 +1,6 @@
%ul.no-list
- if controller_name != 'profiles'
%li= link_to t('settings.edit_profile'), settings_profile_path
- if controller_name != 'follow_requests'
%li= link_to t('follow_requests.title'), follow_requests_path
- if controller_name != 'preferences'
%li= link_to t('settings.preferences'), settings_preferences_path
- if controller_name != 'registrations'

View File

@ -40,10 +40,6 @@ en:
x_minutes: "%{count}m"
x_months: "%{count}mo"
x_seconds: "%{count}s"
follow_requests:
authorize: Authorize
reject: Reject
title: Follow requests
generic:
changes_saved_msg: Changes successfully saved!
powered_by: powered by %{link}

View File

@ -48,13 +48,6 @@ Rails.application.routes.draw do
resources :media, only: [:show]
resources :tags, only: [:show]
resources :follow_requests do
member do
post :authorize
post :reject
end
end
namespace :admin do
resources :pubsubhubbub, only: [:index]
resources :domain_blocks, only: [:index, :create]

View File

@ -1,16 +0,0 @@
require 'rails_helper'
RSpec.describe FollowRequestsController, type: :controller do
render_views
before do
sign_in Fabricate(:user), scope: :user
end
describe 'GET #index' do
it 'returns http success' do
get :index
expect(response).to have_http_status(:success)
end
end
end

View File

@ -1,5 +0,0 @@
require 'rails_helper'
RSpec.describe FollowRequestsHelper, type: :helper do
end