diff --git a/app/controllers/api/v1/groups/requests_controller.rb b/app/controllers/api/v1/groups/requests_controller.rb new file mode 100644 index 00000000..ab01e85d --- /dev/null +++ b/app/controllers/api/v1/groups/requests_controller.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +class Api::V1::Groups::RequestsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :read, :'read:groups' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:groups' }, except: [:show] + + before_action :require_user! + before_action :set_group + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + authorize @group, :leave? + GroupJoinRequest.where(group: @group, account_id: current_account.id).destroy_all + render json: @group, serializer: REST::GroupRelationshipSerializer, relationships: relationships + end + + def approve_request + GroupJoinRequest.where(group: @group, account_id: params[:accountId]).destroy_all + GroupAccount.create(group: @group, account_id: params[:accountId]) + render json: {"message": "ok"} + end + + def reject_request + GroupJoinRequest.where(group: @group, account_id: params[:accountId]).destroy_all + render json: {"message": "ok"} + end + + private + + def relationships + GroupRelationshipsPresenter.new([@group.id], current_user.account_id) + end + + def set_group + @group = Group.find(params[:group_id]) + end + + def load_accounts + if unlimited? + @group.join_requests.includes(:account_stat).all + else + @group.join_requests.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + if records_continue? + api_v1_group_join_requests_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + return if unlimited? + + unless @accounts.empty? + api_v1_group_join_requests_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end + + def group_account_params + params.permit(:role, :write_permissions) + end +end diff --git a/app/javascript/gabsocial/actions/groups.js b/app/javascript/gabsocial/actions/groups.js index f71180d6..0084333d 100644 --- a/app/javascript/gabsocial/actions/groups.js +++ b/app/javascript/gabsocial/actions/groups.js @@ -55,6 +55,20 @@ export const GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_CRE export const GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS'; export const GROUP_REMOVED_ACCOUNTS_CREATE_FAIL = 'GROUP_REMOVED_ACCOUNTS_CREATE_FAIL'; +export const GROUP_JOIN_REQUESTS_FETCH_REQUEST = 'GROUP_JOIN_REQUESTS_FETCH_REQUEST' +export const GROUP_JOIN_REQUESTS_FETCH_SUCCESS = 'GROUP_JOIN_REQUESTS_FETCH_SUCCESS' +export const GROUP_JOIN_REQUESTS_FETCH_FAIL = 'GROUP_JOIN_REQUESTS_FETCH_FAIL' + +export const GROUP_JOIN_REQUESTS_EXPAND_REQUEST = 'GROUP_JOIN_REQUESTS_EXPAND_REQUEST' +export const GROUP_JOIN_REQUESTS_EXPAND_SUCCESS = 'GROUP_JOIN_REQUESTS_EXPAND_SUCCESS' +export const GROUP_JOIN_REQUESTS_EXPAND_FAIL = 'GROUP_JOIN_REQUESTS_EXPAND_FAIL' + +export const GROUP_JOIN_REQUESTS_APPROVE_SUCCESS = 'GROUP_JOIN_REQUESTS_APPROVE_SUCCESS' +export const GROUP_JOIN_REQUESTS_APPROVE_FAIL = 'GROUP_JOIN_REQUESTS_APPROVE_FAIL' + +export const GROUP_JOIN_REQUESTS_REJECT_SUCCESS = 'GROUP_JOIN_REQUESTS_REJECT_SUCCESS' +export const GROUP_JOIN_REQUESTS_REJECT_FAIL = 'GROUP_JOIN_REQUESTS_REJECT_FAIL' + export const GROUP_REMOVE_STATUS_REQUEST = 'GROUP_REMOVE_STATUS_REQUEST'; export const GROUP_REMOVE_STATUS_SUCCESS = 'GROUP_REMOVE_STATUS_SUCCESS'; export const GROUP_REMOVE_STATUS_FAIL = 'GROUP_REMOVE_STATUS_FAIL'; @@ -596,6 +610,149 @@ export function updateRoleFail(groupId, id, error) { }; +export function fetchJoinRequests(id) { + return (dispatch, getState) => { + if (!me) return + + dispatch(fetchJoinRequestsRequest(id)) + + api(getState).get(`/api/v1/groups/${id}/join_requests`).then((response) => { + const next = getLinks(response).refs.find(link => link.rel === 'next') + + dispatch(importFetchedAccounts(response.data)) + dispatch(fetchJoinRequestsSuccess(id, response.data, next ? next.uri : null)) + dispatch(fetchRelationships(response.data.map(item => item.id))) + }).catch((error) => { + dispatch(fetchJoinRequestsFail(id, error)) + }) + } +} + +export function fetchJoinRequestsRequest(id) { + return { + type: GROUP_JOIN_REQUESTS_FETCH_REQUEST, + id, + } +} + +export function fetchJoinRequestsSuccess(id, accounts, next) { + return { + type: GROUP_JOIN_REQUESTS_FETCH_SUCCESS, + id, + accounts, + next, + } +} + +export function fetchJoinRequestsFail(id, error) { + return { + type: GROUP_JOIN_REQUESTS_FETCH_FAIL, + id, + error, + } +} + +export function expandJoinRequests(id) { + return (dispatch, getState) => { + if (!me) return + + const url = getState().getIn(['user_lists', 'group_join_requests', id, 'next']) + const isLoading = getState().getIn(['user_lists', 'group_join_requests', id, 'isLoading']) + + if (url === null || isLoading) return + + dispatch(expandJoinRequestsRequest(id)) + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next') + + dispatch(importFetchedAccounts(response.data)) + dispatch(expandJoinRequestsSuccess(id, response.data, next ? next.uri : null)) + dispatch(fetchRelationships(response.data.map(item => item.id))) + }).catch(error => { + dispatch(expandJoinRequestsFail(id, error)) + }) + } +} + +export function expandJoinRequestsRequest(id) { + return { + type: GROUP_JOIN_REQUESTS_EXPAND_REQUEST, + id, + } +} + +export function expandJoinRequestsSuccess(id, accounts, next) { + return { + type: GROUP_JOIN_REQUESTS_EXPAND_SUCCESS, + id, + accounts, + next, + } +} + +export function expandJoinRequestsFail(id, error) { + return { + type: GROUP_JOIN_REQUESTS_EXPAND_FAIL, + id, + error, + } +} + +export const approveJoinRequest = (accountId, groupId) => (dispatch, getState) => { + if (!me) return + + api(getState).post(`/api/v1/groups/${groupId}/join_requests/approve`, { accountId }).then((response) => { + dispatch(approveJoinRequestSuccess(accountId, groupId)) + }).catch((error) => { + dispatch(approveJoinRequestFail(accountId, groupId, error)) + }) +} + +export function approveJoinRequestSuccess(accountId, groupId) { + return { + type: GROUP_JOIN_REQUESTS_APPROVE_SUCCESS, + accountId, + groupId, + } +} + +export function approveJoinRequestFail(accountId, groupId, error) { + return { + type: GROUP_JOIN_REQUESTS_APPROVE_FAIL, + accountId, + groupId, + error, + } +} + +export const rejectJoinRequest = (accountId, groupId) => (dispatch, getState) => { + if (!me) return + + api(getState).delete(`/api/v1/groups/${groupId}/join_requests/reject`, { accountId }).then((response) => { + dispatch(rejectJoinRequestSuccess(accountId, groupId)) + }).catch((error) => { + dispatch(rejectJoinRequestFail(accountId, groupId, error)) + }) +} + +export function rejectJoinRequestSuccess(accountId, groupId) { + return { + type: GROUP_JOIN_REQUESTS_REJECT_SUCCESS, + accountId, + groupId, + } +} + +export function rejectJoinRequestFail(accountId, groupId, error) { + return { + type: GROUP_JOIN_REQUESTS_REJECT_FAIL, + accountId, + groupId, + error, + } +} + export function pinGroupStatus(groupId, statusId) { return (dispatch, getState) => { if (!me) return diff --git a/app/javascript/gabsocial/features/group_join_requests.js b/app/javascript/gabsocial/features/group_join_requests.js new file mode 100644 index 00000000..315cb127 --- /dev/null +++ b/app/javascript/gabsocial/features/group_join_requests.js @@ -0,0 +1,130 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import ImmutablePureComponent from 'react-immutable-pure-component' +import ImmutablePropTypes from 'react-immutable-proptypes' +import debounce from 'lodash.debounce' +import isObject from 'lodash.isobject' +import { FormattedMessage } from 'react-intl' +import { me } from '../initial_state' +import { + fetchJoinRequests, + expandJoinRequests, + rejectJoinRequest, + approveJoinRequest, +} from '../actions/groups' +import Account from '../components/account' +import ColumnIndicator from '../components/column_indicator' +import Block from '../components/block' +import BlockHeading from '../components/block_heading' +import ScrollableList from '../components/scrollable_list' + +class GroupJoinRequests extends ImmutablePureComponent { + + componentWillMount() { + const { groupId } = this.props + + this.props.onFetchJoinRequests(groupId) + } + + componentWillReceiveProps(nextProps) { + if (nextProps.groupId !== this.props.groupId) { + this.props.onFetchJoinRequests(nextProps.groupId) + } + } + + handleLoadMore = debounce(() => { + this.props.onExpandJoinRequests(this.props.groupId) + }, 300, { leading: true }) + + render() { + const { + accountIds, + hasMore, + group, + groupId, + relationships, + } = this.props + + if (!group || !relationships) return + + const isAdminOrMod = relationships ? (relationships.get('admin') || relationships.get('moderator')) : false + + if (!isAdminOrMod) return + + return ( + + +
+ } + > + { + accountIds && accountIds.map((id) => ( + { + this.props.onRejectJoinRequest(id, groupId) + }} + actionIcon={(!isAdminOrMod || id === me) ? undefined : 'check'} + onActionClick={(data, event) => { + this.props.onApproveJoinRequest(id, groupId) + }} + /> + )) + } + +
+
+ ) + } + +} + +const mapStateToProps = (state, { params }) => { + const groupId = isObject(params) ? params['id'] : -1 + const group = groupId === -1 ? null : state.getIn(['groups', groupId]) + + return { + group, + groupId, + relationships: state.getIn(['group_relationships', groupId]), + accountIds: state.getIn(['user_lists', 'group_join_requests', groupId, 'items']), + hasMore: !!state.getIn(['user_lists', 'group_join_requests', groupId, 'next']), + } +} + +const mapDispatchToProps = (dispatch) => ({ + onFetchJoinRequests(groupId) { + dispatch(fetchJoinRequests(groupId)) + }, + onExpandJoinRequests(groupId) { + dispatch(expandJoinRequests(groupId)) + }, + onRejectJoinRequest(accountId, groupId) { + dispatch(rejectJoinRequest(accountId, groupId)) + }, + onApproveJoinRequest(accountId, groupId) { + dispatch(approveJoinRequest(accountId, groupId)) + }, +}) + +GroupJoinRequests.propTypes = { + group: ImmutablePropTypes.map, + groupId: PropTypes.string.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + onExpandJoinRequests: PropTypes.func.isRequired, + onFetchJoinRequests: PropTypes.func.isRequired, + onRejectJoinRequest: PropTypes.func.isRequired, + onApproveJoinRequest: PropTypes.func.isRequired, +} + +export default connect(mapStateToProps, mapDispatchToProps)(GroupJoinRequests) \ No newline at end of file diff --git a/app/javascript/gabsocial/features/ui/ui.js b/app/javascript/gabsocial/features/ui/ui.js index 19878037..b7fe266a 100644 --- a/app/javascript/gabsocial/features/ui/ui.js +++ b/app/javascript/gabsocial/features/ui/ui.js @@ -64,6 +64,7 @@ import { GroupCollectionTimeline, GroupCreate, GroupAbout, + GroupJoinRequests, GroupMembers, GroupRemovedAccounts, GroupTimeline, @@ -193,6 +194,7 @@ class SwitchingArea extends React.PureComponent { + diff --git a/app/javascript/gabsocial/features/ui/util/async_components.js b/app/javascript/gabsocial/features/ui/util/async_components.js index 8bbe4544..e67a56c0 100644 --- a/app/javascript/gabsocial/features/ui/util/async_components.js +++ b/app/javascript/gabsocial/features/ui/util/async_components.js @@ -37,7 +37,7 @@ export function GroupCreate() { return import(/* webpackChunkName: "features/gro export function GroupCreateModal() { return import(/* webpackChunkName: "components/group_create_modal" */'../../../components/modal/group_create_modal') } export function GroupDeleteModal() { return import(/* webpackChunkName: "components/group_delete_modal" */'../../../components/modal/group_delete_modal') } export function GroupInfoPanel() { return import(/* webpackChunkName: "components/group_info_panel" */'../../../components/panel/group_info_panel') } -// export function GroupJoinRequests() { return import(/* webpackChunkName: "features/group_join_requests" */'../../group_join_requests') } +export function GroupJoinRequests() { return import(/* webpackChunkName: "features/group_join_requests" */'../../group_join_requests') } export function GroupListSortOptionsPopover() { return import(/* webpackChunkName: "components/group_list_sort_options_popover" */'../../../components/popover/group_list_sort_options_popover') } export function GroupMemberOptionsPopover() { return import(/* webpackChunkName: "components/group_member_options_popover" */'../../../components/popover/group_member_options_popover') } export function GroupMembers() { return import(/* webpackChunkName: "features/group_members" */'../../group_members') } diff --git a/app/javascript/gabsocial/pages/group_page.js b/app/javascript/gabsocial/pages/group_page.js index 60990481..90d07cb4 100644 --- a/app/javascript/gabsocial/pages/group_page.js +++ b/app/javascript/gabsocial/pages/group_page.js @@ -8,6 +8,8 @@ import { fetchGroup } from '../actions/groups' import PageTitle from '../features/ui/util/page_title' import GroupLayout from '../layouts/group_layout' import TimelineComposeBlock from '../components/timeline_compose_block' +import Block from '../components/block' +import ColumnIndicator from '../components/column_indicator' import Divider from '../components/divider' class GroupPage extends ImmutablePureComponent { @@ -27,6 +29,11 @@ class GroupPage extends ImmutablePureComponent { const groupTitle = !!group ? group.get('title') : '' const groupId = !!group ? group.get('id') : undefined + + const isPrivate = !!group ? group.get('is_private') : false + const isMember = !!relationships ? relationships.get('member') : false + const unavailable = isPrivate && !isMember + return ( - + { - !!relationships && isTimeline && relationships.get('member') && + !!relationships && isTimeline && isMember && } - {children} + { + unavailable && + + + + } + + { + !unavailable && children + } ) } @@ -53,6 +69,7 @@ class GroupPage extends ImmutablePureComponent { const messages = defineMessages({ group: { id: 'group', defaultMessage: 'Group' }, + groupPrivate: { id: 'group_private', defaultMessage: 'This group is private. You must request to join in order to view this group.' }, }) const mapStateToProps = (state, { params: { id } }) => ({ diff --git a/app/javascript/gabsocial/reducers/user_lists.js b/app/javascript/gabsocial/reducers/user_lists.js index 9729f8c8..76a718d3 100644 --- a/app/javascript/gabsocial/reducers/user_lists.js +++ b/app/javascript/gabsocial/reducers/user_lists.js @@ -48,6 +48,10 @@ import { GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS, GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS, GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS, + GROUP_JOIN_REQUESTS_FETCH_SUCCESS, + GROUP_JOIN_REQUESTS_EXPAND_SUCCESS, + GROUP_JOIN_REQUESTS_APPROVE_SUCCESS, + GROUP_JOIN_REQUESTS_REJECT_SUCCESS, } from '../actions/groups' const initialState = ImmutableMap({ @@ -60,6 +64,7 @@ const initialState = ImmutableMap({ mutes: ImmutableMap(), groups: ImmutableMap(), group_removed_accounts: ImmutableMap(), + group_join_requests: ImmutableMap(), }); const setListFailed = (state, type, id) => { @@ -168,6 +173,14 @@ export default function userLists(state = initialState, action) { case GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS: return state.updateIn(['group_removed_accounts', action.groupId, 'items'], list => list.filterNot(item => item === action.id)); + case GROUP_JOIN_REQUESTS_FETCH_SUCCESS: + return normalizeList(state, 'group_join_requests', action.id, action.accounts, action.next); + case GROUP_JOIN_REQUESTS_EXPAND_SUCCESS: + return appendToList(state, 'group_join_requests', action.id, action.accounts, action.next); + case GROUP_JOIN_REQUESTS_APPROVE_SUCCESS: + case GROUP_JOIN_REQUESTS_REJECT_SUCCESS: + return state.updateIn(['group_join_requests', action.groupId, 'items'], list => list.filterNot(item => item === action.id)); + default: return state; } diff --git a/app/models/concerns/group_interactions.rb b/app/models/concerns/group_interactions.rb index 6bb973bd..02321862 100644 --- a/app/models/concerns/group_interactions.rb +++ b/app/models/concerns/group_interactions.rb @@ -17,6 +17,10 @@ module GroupInteractions follow_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id, role: :moderator), :group_id) end + def requested_map(target_group_ids, account_id) + follow_mapping(GroupJoinRequest.where(group_id: target_group_ids, account_id: account_id), :group_id) + end + def pinned?(status) group_pinned_statuses.where(group_id: status.group_id, status: status).exists? end diff --git a/app/models/group.rb b/app/models/group.rb index 5f2cced8..1673ac7e 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -35,6 +35,9 @@ class Group < ApplicationRecord has_many :group_accounts, inverse_of: :group, dependent: :destroy has_many :accounts, through: :group_accounts + + has_many :group_join_requests, inverse_of: :group, dependent: :destroy + has_many :join_requests, source: :account, through: :group_join_requests has_many :group_pinned_statuses, inverse_of: :group, dependent: :destroy has_many :pinned_statuses, source: :status, through: :group_pinned_statuses diff --git a/app/models/group_join_request.rb b/app/models/group_join_request.rb new file mode 100644 index 00000000..dbcb4a4e --- /dev/null +++ b/app/models/group_join_request.rb @@ -0,0 +1,17 @@ +# == Schema Information +# +# Table name: group_join_requests +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# group_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class GroupJoinRequest < ApplicationRecord + belongs_to :group + belongs_to :account + + validates :account_id, uniqueness: { scope: :group_id } +end diff --git a/app/presenters/group_relationships_presenter.rb b/app/presenters/group_relationships_presenter.rb index a8c9a8a7..7b4e4d53 100644 --- a/app/presenters/group_relationships_presenter.rb +++ b/app/presenters/group_relationships_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class GroupRelationshipsPresenter - attr_reader :member, :admin, :moderator + attr_reader :member, :admin, :moderator, :requested def initialize(group_ids, current_account_id, **options) @group_ids = group_ids.map { |a| a.is_a?(Group) ? a.id : a } @@ -10,6 +10,7 @@ class GroupRelationshipsPresenter @member = Group.member_map(@group_ids, @current_account_id) @admin = Group.admin_map(@group_ids, @current_account_id) @moderator = Group.moderator_map(@group_ids, @current_account_id) + @requested = Group.requested_map(@group_ids, @current_account_id) end end \ No newline at end of file diff --git a/app/serializers/rest/group_relationship_serializer.rb b/app/serializers/rest/group_relationship_serializer.rb index 233de329..4ad24a67 100644 --- a/app/serializers/rest/group_relationship_serializer.rb +++ b/app/serializers/rest/group_relationship_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::GroupRelationshipSerializer < ActiveModel::Serializer - attributes :id, :member, :admin, :moderator + attributes :id, :member, :admin, :moderator, :requested def id object.id.to_s @@ -19,4 +19,8 @@ class REST::GroupRelationshipSerializer < ActiveModel::Serializer instance_options[:relationships].moderator[object.id] ? true : false end + def requested + instance_options[:relationships].requested[object.id] ? true : false + end + end diff --git a/config/routes.rb b/config/routes.rb index 829feb4f..b3ad7628 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -440,6 +440,11 @@ Rails.application.routes.draw do resources :relationships, only: :index, controller: 'groups/relationships' resource :accounts, only: [:show, :create, :update, :destroy], controller: 'groups/accounts' resource :removed_accounts, only: [:show, :create, :destroy], controller: 'groups/removed_accounts' + resource :join_requests, only: [:show, :create], controller: 'groups/requests' + + post '/join_requests/approve', to: 'groups/requests#approve_request' + delete '/join_requests/reject', to: 'groups/requests#reject_request' + resource :pin, only: :create, controller: 'groups/pins' post :unpin, to: 'groups/pins#destroy' end