diff --git a/app/controllers/api/v1/groups/accounts_controller.rb b/app/controllers/api/v1/groups/accounts_controller.rb
index 8a31f9f5..e09b21e2 100644
--- a/app/controllers/api/v1/groups/accounts_controller.rb
+++ b/app/controllers/api/v1/groups/accounts_controller.rb
@@ -9,7 +9,7 @@ class Api::V1::Groups::AccountsController < Api::BaseController
before_action :require_user!
before_action :set_group
- after_action :insert_pagination_headers, only: :index
+ after_action :insert_pagination_headers, only: :show
def show
@accounts = load_accounts
diff --git a/app/controllers/api/v1/groups/removed_accounts_controller.rb b/app/controllers/api/v1/groups/removed_accounts_controller.rb
new file mode 100644
index 00000000..d6cba234
--- /dev/null
+++ b/app/controllers/api/v1/groups/removed_accounts_controller.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+class Api::V1::Groups::RemovedAccountsController < Api::BaseController
+ include Authorization
+
+ before_action -> { doorkeeper_authorize! :write, :'write:groups' }
+
+ before_action :require_user!
+ before_action :set_group
+
+ after_action :insert_pagination_headers, only: :show
+
+ def show
+ authorize @group, :show_removed_accounts?
+
+ @accounts = load_accounts
+ render json: @accounts, each_serializer: REST::AccountSerializer
+ end
+
+ def create
+ authorize @group, :create_removed_account?
+
+ @account = @group.accounts.find(params[:account_id])
+ @group.removed_accounts << @account
+ GroupAccount.where(group: @group, account: @account).destroy_all
+ render_empty
+ end
+
+ def destroy
+ authorize @group, :destroy_removed_account?
+
+ @account = @group.removed_accounts.find(params[:account_id])
+ GroupRemovedAccount.where(group: @group, account: @account).destroy_all
+ render_empty
+ end
+
+ private
+
+ def set_group
+ @group = Group.find(params[:group_id])
+ end
+
+ def load_accounts
+ if unlimited?
+ @group.removed_accounts.includes(:account_stat).all
+ else
+ @group.removed_accounts.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_removed_accounts_url pagination_params(max_id: pagination_max_id)
+ end
+ end
+
+ def prev_path
+ return if unlimited?
+
+ unless @accounts.empty?
+ api_v1_group_removed_accounts_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
+end
diff --git a/app/controllers/api/v1/groups_controller.rb b/app/controllers/api/v1/groups_controller.rb
index 8299f788..837540f6 100644
--- a/app/controllers/api/v1/groups_controller.rb
+++ b/app/controllers/api/v1/groups_controller.rb
@@ -10,10 +10,24 @@ class Api::V1::GroupsController < Api::BaseController
before_action :set_group, except: [:index, :create]
def index
- @groups = Group.joins(:group_accounts).where(is_archived: false, group_accounts: { account: current_account }).all
+ case current_tab
+ when 'featured'
+ @groups = Group.where(is_featured: true).limit(25).all
+ when 'member'
+ @groups = Group.joins(:group_accounts).where(is_archived: false, group_accounts: { account: current_account }).order('group_accounts.unread_count DESC, group_accounts.id DESC').all
+ when 'admin'
+ @groups = Group.joins(:group_accounts).where(is_archived: false, group_accounts: { account: current_account, role: :admin }).all
+ end
+
render json: @groups, each_serializer: REST::GroupSerializer
end
+ def current_tab
+ tab = 'featured'
+ tab = params[:tab] if ['featured', 'member', 'admin'].include? params[:tab]
+ return tab
+ end
+
def show
render json: @group, serializer: REST::GroupSerializer
end
diff --git a/app/controllers/api/v1/timelines/group_controller.rb b/app/controllers/api/v1/timelines/group_controller.rb
index e7b36fa8..b19f3961 100644
--- a/app/controllers/api/v1/timelines/group_controller.rb
+++ b/app/controllers/api/v1/timelines/group_controller.rb
@@ -9,6 +9,8 @@ class Api::V1::Timelines::GroupController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
+ mark_as_unread
+
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
@@ -16,6 +18,10 @@ class Api::V1::Timelines::GroupController < Api::BaseController
private
+ def mark_as_unread
+ GroupAccount.where(group: @group, account: current_account).update_all("unread_count = 0")
+ end
+
def set_group
@group = Group.find(params[:id])
end
@@ -29,7 +35,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController
end
def group_statuses
- statuses = tag_timeline_statuses.paginate_by_id(
+ statuses = group_timeline_statuses.without_replies.paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
@@ -43,7 +49,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController
end
end
- def group_statuses
+ def group_timeline_statuses
GroupQueryService.new.call(@group)
end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 930ff70e..e9d6c5d8 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -50,6 +50,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_aggregate_reblogs,
:setting_show_application,
:setting_advanced_layout,
+ :setting_group_in_home_feed,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following)
)
diff --git a/app/javascript/gabsocial/actions/compose.js b/app/javascript/gabsocial/actions/compose.js
index f1cef65f..e377ece9 100644
--- a/app/javascript/gabsocial/actions/compose.js
+++ b/app/javascript/gabsocial/actions/compose.js
@@ -125,7 +125,7 @@ export function directCompose(account, routerHistory) {
};
};
-export function submitCompose(routerHistory) {
+export function submitCompose(routerHistory, group) {
return function (dispatch, getState) {
if (!me) return;
@@ -147,6 +147,7 @@ export function submitCompose(routerHistory) {
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
+ group_id: group ? group.get('id') : null,
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
diff --git a/app/javascript/gabsocial/actions/group_editor.js b/app/javascript/gabsocial/actions/group_editor.js
new file mode 100644
index 00000000..e464c1c6
--- /dev/null
+++ b/app/javascript/gabsocial/actions/group_editor.js
@@ -0,0 +1,113 @@
+import api from '../api';
+import { me } from 'gabsocial/initial_state';
+
+export const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
+export const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
+export const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL';
+
+export const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST';
+export const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS';
+export const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL';
+
+export const GROUP_EDITOR_VALUE_CHANGE = 'GROUP_EDITOR_VALUE_CHANGE';
+export const GROUP_EDITOR_RESET = 'GROUP_EDITOR_RESET';
+export const GROUP_EDITOR_SETUP = 'GROUP_EDITOR_SETUP';
+
+export const submit = (routerHistory) => (dispatch, getState) => {
+ const groupId = getState().getIn(['group_editor', 'groupId']);
+ const title = getState().getIn(['group_editor', 'title']);
+ const description = getState().getIn(['group_editor', 'description']);
+ const coverImage = getState().getIn(['group_editor', 'coverImage']);
+
+ if (groupId === null) {
+ dispatch(create(title, description, coverImage, routerHistory));
+ } else {
+ dispatch(update(groupId, title, description, coverImage, routerHistory));
+ }
+};
+
+
+export const create = (title, description, coverImage, routerHistory) => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(createRequest());
+
+ const formData = new FormData();
+ formData.append('title', title);
+ formData.append('description', description);
+
+ if (coverImage !== null) {
+ formData.append('cover_image', coverImage);
+ }
+
+ api(getState).post('/api/v1/groups', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => {
+ dispatch(createSuccess(data));
+ routerHistory.push(`/groups/${data.id}`);
+ }).catch(err => dispatch(createFail(err)));
+ };
+
+
+export const createRequest = id => ({
+ type: GROUP_CREATE_REQUEST,
+ id,
+});
+
+export const createSuccess = group => ({
+ type: GROUP_CREATE_SUCCESS,
+ group,
+});
+
+export const createFail = error => ({
+ type: GROUP_CREATE_FAIL,
+ error,
+});
+
+export const update = (groupId, title, description, coverImage, routerHistory) => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(updateRequest());
+
+ const formData = new FormData();
+ formData.append('title', title);
+ formData.append('description', description);
+
+ if (coverImage !== null) {
+ formData.append('cover_image', coverImage);
+ }
+
+ api(getState).put(`/api/v1/groups/${groupId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(({ data }) => {
+ dispatch(updateSuccess(data));
+ routerHistory.push(`/groups/${data.id}`);
+ }).catch(err => dispatch(updateFail(err)));
+ };
+
+
+export const updateRequest = id => ({
+ type: GROUP_UPDATE_REQUEST,
+ id,
+});
+
+export const updateSuccess = group => ({
+ type: GROUP_UPDATE_SUCCESS,
+ group,
+});
+
+export const updateFail = error => ({
+ type: GROUP_UPDATE_FAIL,
+ error,
+});
+
+export const changeValue = (field, value) => ({
+ type: GROUP_EDITOR_VALUE_CHANGE,
+ field,
+ value,
+});
+
+export const reset = () => ({
+ type: GROUP_EDITOR_RESET
+});
+
+export const setUp = (group) => ({
+ type: GROUP_EDITOR_SETUP,
+ group,
+});
\ No newline at end of file
diff --git a/app/javascript/gabsocial/actions/groups.js b/app/javascript/gabsocial/actions/groups.js
index 0de668b8..eac34371 100644
--- a/app/javascript/gabsocial/actions/groups.js
+++ b/app/javascript/gabsocial/actions/groups.js
@@ -1,5 +1,7 @@
-import api from '../api';
+import api, { getLinks } from '../api';
import { me } from 'gabsocial/initial_state';
+import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
export const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST';
export const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS';
@@ -21,6 +23,34 @@ export const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST';
export const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS';
export const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL';
+export const GROUP_MEMBERS_FETCH_REQUEST = 'GROUP_MEMBERS_FETCH_REQUEST';
+export const GROUP_MEMBERS_FETCH_SUCCESS = 'GROUP_MEMBERS_FETCH_SUCCESS';
+export const GROUP_MEMBERS_FETCH_FAIL = 'GROUP_MEMBERS_FETCH_FAIL';
+
+export const GROUP_MEMBERS_EXPAND_REQUEST = 'GROUP_MEMBERS_EXPAND_REQUEST';
+export const GROUP_MEMBERS_EXPAND_SUCCESS = 'GROUP_MEMBERS_EXPAND_SUCCESS';
+export const GROUP_MEMBERS_EXPAND_FAIL = 'GROUP_MEMBERS_EXPAND_FAIL';
+
+export const GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST = 'GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST';
+export const GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS';
+export const GROUP_REMOVED_ACCOUNTS_FETCH_FAIL = 'GROUP_REMOVED_ACCOUNTS_FETCH_FAIL';
+
+export const GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST = 'GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST';
+export const GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS';
+export const GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL = 'GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL';
+
+export const GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST';
+export const GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS = 'GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS';
+export const GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL = 'GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL';
+
+export const GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST = 'GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST';
+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_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';
+
export const fetchGroup = id => (dispatch, getState) => {
if (!me) return;
@@ -98,13 +128,16 @@ export function fetchGroupRelationshipsFail(error) {
};
};
-export const fetchGroups = () => (dispatch, getState) => {
+export const fetchGroups = (tab) => (dispatch, getState) => {
if (!me) return;
dispatch(fetchGroupsRequest());
- api(getState).get('/api/v1/groups')
- .then(({ data }) => dispatch(fetchGroupsSuccess(data)))
+ api(getState).get('/api/v1/groups?tab=' + tab)
+ .then(({ data }) => {
+ dispatch(fetchGroupsSuccess(data, tab));
+ dispatch(fetchGroupRelationships(data.map(item => item.id)));
+ })
.catch(err => dispatch(fetchGroupsFail(err)));
};
@@ -112,9 +145,10 @@ export const fetchGroupsRequest = () => ({
type: GROUPS_FETCH_REQUEST,
});
-export const fetchGroupsSuccess = groups => ({
+export const fetchGroupsSuccess = (groups, tab) => ({
type: GROUPS_FETCH_SUCCESS,
groups,
+ tab,
});
export const fetchGroupsFail = error => ({
@@ -191,3 +225,300 @@ export function leaveGroupFail(error) {
error,
};
};
+
+export function fetchMembers(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchMembersRequest(id));
+
+ api(getState).get(`/api/v1/groups/${id}/accounts`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(fetchMembersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchMembersFail(id, error));
+ });
+ };
+};
+
+export function fetchMembersRequest(id) {
+ return {
+ type: GROUP_MEMBERS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchMembersSuccess(id, accounts, next) {
+ return {
+ type: GROUP_MEMBERS_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchMembersFail(id, error) {
+ return {
+ type: GROUP_MEMBERS_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandMembers(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ const url = getState().getIn(['user_lists', 'groups', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandMembersRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(expandMembersSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandMembersFail(id, error));
+ });
+ };
+};
+
+export function expandMembersRequest(id) {
+ return {
+ type: GROUP_MEMBERS_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandMembersSuccess(id, accounts, next) {
+ return {
+ type: GROUP_MEMBERS_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandMembersFail(id, error) {
+ return {
+ type: GROUP_MEMBERS_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function fetchRemovedAccounts(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchRemovedAccountsRequest(id));
+
+ api(getState).get(`/api/v1/groups/${id}/removed_accounts`).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(fetchRemovedAccountsSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchRemovedAccountsFail(id, error));
+ });
+ };
+};
+
+export function fetchRemovedAccountsRequest(id) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchRemovedAccountsSuccess(id, accounts, next) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function fetchRemovedAccountsFail(id, error) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_FETCH_FAIL,
+ id,
+ error,
+ };
+};
+
+export function expandRemovedAccounts(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ const url = getState().getIn(['user_lists', 'groups_removed_accounts', id, 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandRemovedAccountsRequest(id));
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(expandRemovedAccountsSuccess(id, response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandRemovedAccountsFail(id, error));
+ });
+ };
+};
+
+export function expandRemovedAccountsRequest(id) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_EXPAND_REQUEST,
+ id,
+ };
+};
+
+export function expandRemovedAccountsSuccess(id, accounts, next) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS,
+ id,
+ accounts,
+ next,
+ };
+};
+
+export function expandRemovedAccountsFail(id, error) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_EXPAND_FAIL,
+ id,
+ error,
+ };
+};
+
+export function removeRemovedAccount(groupId, id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(removeRemovedAccountRequest(groupId, id));
+
+ api(getState).delete(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => {
+ dispatch(removeRemovedAccountSuccess(groupId, id));
+ }).catch(error => {
+ dispatch(removeRemovedAccountFail(groupId, id, error));
+ });
+ };
+};
+
+export function removeRemovedAccountRequest(groupId, id) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_REMOVE_REQUEST,
+ groupId,
+ id,
+ };
+};
+
+export function removeRemovedAccountSuccess(groupId, id) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS,
+ groupId,
+ id,
+ };
+};
+
+export function removeRemovedAccountFail(groupId, id, error) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_REMOVE_FAIL,
+ groupId,
+ id,
+ error,
+ };
+};
+
+export function createRemovedAccount(groupId, id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(createRemovedAccountRequest(groupId, id));
+
+ api(getState).post(`/api/v1/groups/${groupId}/removed_accounts?account_id=${id}`).then(response => {
+ dispatch(createRemovedAccountSuccess(groupId, id));
+ }).catch(error => {
+ dispatch(createRemovedAccountFail(groupId, id, error));
+ });
+ };
+};
+
+export function createRemovedAccountRequest(groupId, id) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST,
+ groupId,
+ id,
+ };
+};
+
+export function createRemovedAccountSuccess(groupId, id) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS,
+ groupId,
+ id,
+ };
+};
+
+export function createRemovedAccountFail(groupId, id, error) {
+ return {
+ type: GROUP_REMOVED_ACCOUNTS_CREATE_FAIL,
+ groupId,
+ id,
+ error,
+ };
+};
+
+export function groupRemoveStatus(groupId, id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(groupRemoveStatusRequest(groupId, id));
+
+ api(getState).delete(`/api/v1/groups/${groupId}/statuses/${id}`).then(response => {
+ dispatch(groupRemoveStatusSuccess(groupId, id));
+ }).catch(error => {
+ dispatch(groupRemoveStatusFail(groupId, id, error));
+ });
+ };
+};
+
+export function groupRemoveStatusRequest(groupId, id) {
+ return {
+ type: GROUP_REMOVE_STATUS_REQUEST,
+ groupId,
+ id,
+ };
+};
+
+export function groupRemoveStatusSuccess(groupId, id) {
+ return {
+ type: GROUP_REMOVE_STATUS_SUCCESS,
+ groupId,
+ id,
+ };
+};
+
+export function groupRemoveStatusFail(groupId, id, error) {
+ return {
+ type: GROUP_REMOVE_STATUS_FAIL,
+ groupId,
+ id,
+ error,
+ };
+};
\ No newline at end of file
diff --git a/app/javascript/gabsocial/components/status_action_bar.js b/app/javascript/gabsocial/components/status_action_bar.js
index bd677581..e27a9bcd 100644
--- a/app/javascript/gabsocial/components/status_action_bar.js
+++ b/app/javascript/gabsocial/components/status_action_bar.js
@@ -1,3 +1,4 @@
+
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
@@ -36,6 +37,8 @@ const messages = defineMessages({
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
+ group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
+ group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove status from group' },
});
class StatusActionBar extends ImmutablePureComponent {
@@ -59,6 +62,7 @@ class StatusActionBar extends ImmutablePureComponent {
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
withDismiss: PropTypes.bool,
+ withGroupAdmin: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@@ -164,9 +168,21 @@ class StatusActionBar extends ImmutablePureComponent {
document.body.removeChild(textarea);
}
}
+
+ handleGroupRemoveAccount = () => {
+ const { status } = this.props;
+
+ this.props.onGroupRemoveAccount(status.get('group_id'), status.getIn(['account', 'id']));
+ }
+
+ handleGroupRemovePost = () => {
+ const { status } = this.props;
+
+ this.props.onGroupRemoveStatus(status.get('group_id'), status.get('id'));
+ }
_makeMenu = (publicStatus) => {
- const { status, intl, withDismiss } = this.props;
+ const { status, intl, withDismiss, withGroupAdmin } = this.props;
const mutingConversation = status.get('muted');
let menu = [];
@@ -213,6 +229,12 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
}
+
+ if (withGroupAdmin) {
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.group_remove_account), action: this.handleGroupRemoveAccount });
+ menu.push({ text: intl.formatMessage(messages.group_remove_post), action: this.handleGroupRemovePost });
+ }
}
return menu;
diff --git a/app/javascript/gabsocial/components/status_list.js b/app/javascript/gabsocial/components/status_list.js
index 03df0c25..dad1889b 100644
--- a/app/javascript/gabsocial/components/status_list.js
+++ b/app/javascript/gabsocial/components/status_list.js
@@ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent {
timelineId: PropTypes.string,
queuedItemSize: PropTypes.number,
onDequeueTimeline: PropTypes.func,
+ withGroupAdmin: PropTypes.bool,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
};
@@ -84,7 +85,7 @@ export default class StatusList extends ImmutablePureComponent {
}
render () {
- const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, ...other } = this.props;
+ const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, ...other } = this.props;
if (isPartial) {
return (
@@ -114,6 +115,7 @@ export default class StatusList extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
+ withGroupAdmin={withGroupAdmin}
showThread
/>
))
diff --git a/app/javascript/gabsocial/containers/status_container.js b/app/javascript/gabsocial/containers/status_container.js
index a89db4bc..f4bfc496 100644
--- a/app/javascript/gabsocial/containers/status_container.js
+++ b/app/javascript/gabsocial/containers/status_container.js
@@ -29,6 +29,10 @@ import { openModal } from '../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal } from '../initial_state';
import { showAlertForError } from '../actions/alerts';
+import {
+ createRemovedAccount,
+ groupRemoveStatus
+} from '../actions/groups';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -173,6 +177,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
+ onGroupRemoveAccount(groupId, accountId) {
+ dispatch(createRemovedAccount(groupId, accountId));
+ },
+
+ onGroupRemoveStatus(groupId, statusId) {
+ dispatch(groupRemoveStatus(groupId, statusId));
+ },
+
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/gabsocial/features/compose/components/compose_form.js b/app/javascript/gabsocial/features/compose/components/compose_form.js
index 03818e2a..8dd548ae 100644
--- a/app/javascript/gabsocial/features/compose/components/compose_form.js
+++ b/app/javascript/gabsocial/features/compose/components/compose_form.js
@@ -68,6 +68,7 @@ class ComposeForm extends ImmutablePureComponent {
anyMedia: PropTypes.bool,
shouldCondense: PropTypes.bool,
autoFocus: PropTypes.bool,
+ group: ImmutablePropTypes.map,
isModalOpen: PropTypes.bool,
};
@@ -119,7 +120,7 @@ class ComposeForm extends ImmutablePureComponent {
return;
}
- this.props.onSubmit(this.context.router ? this.context.router.history : null);
+ this.props.onSubmit(this.context.router ? this.context.router.history : null, this.props.group);
}
onSuggestionsClearRequested = () => {
diff --git a/app/javascript/gabsocial/features/compose/containers/compose_form_container.js b/app/javascript/gabsocial/features/compose/containers/compose_form_container.js
index 77ce2b48..c7c5618d 100644
--- a/app/javascript/gabsocial/features/compose/containers/compose_form_container.js
+++ b/app/javascript/gabsocial/features/compose/containers/compose_form_container.js
@@ -34,8 +34,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeCompose(text));
},
- onSubmit (router) {
- dispatch(submitCompose(router));
+ onSubmit (router, group) {
+ dispatch(submitCompose(router, group));
},
onClearSuggestions () {
diff --git a/app/javascript/gabsocial/features/groups/create/index.js b/app/javascript/gabsocial/features/groups/create/index.js
index b7f3a41b..b99553ef 100644
--- a/app/javascript/gabsocial/features/groups/create/index.js
+++ b/app/javascript/gabsocial/features/groups/create/index.js
@@ -1,78 +1,111 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
-import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
-import IconButton from '../../../components/icon_button';
+import { changeValue, submit, reset } from '../../../actions/group_editor';
+import Icon from '../../../components/icon';
import { defineMessages, injectIntl } from 'react-intl';
+import classNames from 'classnames';
const messages = defineMessages({
- label: { id: 'groups.new.title_placeholder', defaultMessage: 'New group title' },
- title: { id: 'groups.new.create', defaultMessage: 'Add group' },
+ title: { id: 'groups.form.title', defaultMessage: 'Enter a new group title' },
+ description: { id: 'groups.form.description', defaultMessage: 'Enter the group description' },
+ coverImage: { id: 'groups.form.coverImage', defaultMessage: 'Upload a banner image' },
+ coverImageChange: { id: 'groups.form.coverImageChange', defaultMessage: 'Banner image selected' },
+ create: { id: 'groups.form.create', defaultMessage: 'Create group' },
});
const mapStateToProps = state => ({
- value: state.getIn(['groupEditor', 'title']),
- disabled: state.getIn(['groupEditor', 'isSubmitting']),
+ title: state.getIn(['group_editor', 'title']),
+ description: state.getIn(['group_editor', 'description']),
+ coverImage: state.getIn(['group_editor', 'coverImage']),
+ disabled: state.getIn(['group_editor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
- onChange: value => dispatch(changeListEditorTitle(value)),
- onSubmit: () => dispatch(submitListEditor(true)),
+ onTitleChange: value => dispatch(changeValue('title', value)),
+ onDescriptionChange: value => dispatch(changeValue('description', value)),
+ onCoverImageChange: value => dispatch(changeValue('coverImage', value)),
+ onSubmit: routerHistory => dispatch(submit(routerHistory)),
+ reset: () => dispatch(reset()),
});
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class Create extends React.PureComponent {
- static propTypes = {
- value: PropTypes.string.isRequired,
- disabled: PropTypes.bool,
- intl: PropTypes.object.isRequired,
- onChange: PropTypes.func.isRequired,
- onSubmit: PropTypes.func.isRequired,
- };
+ static contextTypes = {
+ router: PropTypes.object
+ }
- handleChange = e => {
- this.props.onChange(e.target.value);
- }
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+ coverImage: PropTypes.object,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onTitleChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ };
- handleSubmit = e => {
- e.preventDefault();
- this.props.onSubmit();
- }
+ componentWillMount() {
+ this.props.reset();
+ }
- handleClick = () => {
- this.props.onSubmit();
- }
+ handleTitleChange = e => {
+ this.props.onTitleChange(e.target.value);
+ }
- render () {
- const { value, disabled, intl } = this.props;
+ handleDescriptionChange = e => {
+ this.props.onDescriptionChange(e.target.value);
+ }
- const label = intl.formatMessage(messages.label);
- const title = intl.formatMessage(messages.title);
+ handleCoverImageChange = e => {
+ this.props.onCoverImageChange(e.target.files[0]);
+ }
- return (
-
- );
- }
+ return (
+
+ );
+ }
}
diff --git a/app/javascript/gabsocial/features/groups/edit/index.js b/app/javascript/gabsocial/features/groups/edit/index.js
new file mode 100644
index 00000000..a58e4b5d
--- /dev/null
+++ b/app/javascript/gabsocial/features/groups/edit/index.js
@@ -0,0 +1,146 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { changeValue, submit, setUp } from '../../../actions/group_editor';
+import Icon from '../../../components/icon';
+import { defineMessages, injectIntl } from 'react-intl';
+import LoadingIndicator from '../../../components/loading_indicator';
+import Column from '../../../components/column';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+ title: { id: 'groups.form.title', defaultMessage: 'Title' },
+ description: { id: 'groups.form.description', defaultMessage: 'Description' },
+ coverImage: { id: 'groups.form.coverImage', defaultMessage: 'Upload new banner image (optional)' },
+ coverImageChange: { id: 'groups.form.coverImageChange', defaultMessage: 'Banner image selected' },
+ update: { id: 'groups.form.update', defaultMessage: 'Update group' },
+});
+
+const mapStateToProps = (state, props) => ({
+ group: state.getIn(['groups', props.params.id]),
+ title: state.getIn(['group_editor', 'title']),
+ description: state.getIn(['group_editor', 'description']),
+ coverImage: state.getIn(['group_editor', 'coverImage']),
+ disabled: state.getIn(['group_editor', 'isSubmitting']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onTitleChange: value => dispatch(changeValue('title', value)),
+ onDescriptionChange: value => dispatch(changeValue('description', value)),
+ onCoverImageChange: value => dispatch(changeValue('coverImage', value)),
+ onSubmit: routerHistory => dispatch(submit(routerHistory)),
+ setUp: group => dispatch(setUp(group)),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class Edit extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object
+ }
+
+ static propTypes = {
+ group: ImmutablePropTypes.map,
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+ coverImage: PropTypes.object,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ onTitleChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ };
+
+ componentWillMount(nextProps) {
+ if (this.props.group) {
+ this.props.setUp(this.props.group);
+ }
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (!this.props.group && nextProps.group) {
+ this.props.setUp(nextProps.group);
+ }
+ }
+
+ handleTitleChange = e => {
+ this.props.onTitleChange(e.target.value);
+ }
+
+ handleDescriptionChange = e => {
+ this.props.onDescriptionChange(e.target.value);
+ }
+
+ handleCoverImageChange = e => {
+ this.props.onCoverImageChange(e.target.files[0]);
+ }
+
+ handleSubmit = e => {
+ e.preventDefault();
+ this.props.onSubmit(this.context.router.history);
+ }
+
+ handleClick = () => {
+ this.props.onSubmit(this.context.router.history);
+ }
+
+ render () {
+ const { group, title, description, coverImage, disabled, intl } = this.props;
+
+ if (typeof group === 'undefined') {
+ return (
+
+
+
+ );
+ } else if (group === false) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/gabsocial/features/groups/index/card.js b/app/javascript/gabsocial/features/groups/index/card.js
new file mode 100644
index 00000000..82a52a16
--- /dev/null
+++ b/app/javascript/gabsocial/features/groups/index/card.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { shortNumberFormat } from '../../../utils/numbers';
+import { connect } from 'react-redux';
+
+const messages = defineMessages({
+ members: { id: 'groups.card.members', defaultMessage: 'Members' },
+ view: { id: 'groups.card.view', defaultMessage: 'View' },
+ join: { id: 'groups.card.join', defaultMessage: 'Join' },
+ role_member: { id: 'groups.card.roles.member', defaultMessage: 'You\'re a member' },
+ role_admin: { id: 'groups.card.roles.admin', defaultMessage: 'You\'re an admin' },
+});
+
+const mapStateToProps = (state, { id }) => ({
+ group: state.getIn(['groups', id]),
+ relationships: state.getIn(['group_relationships', id]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class GroupCard extends ImmutablePureComponent {
+ static propTypes = {
+ group: ImmutablePropTypes.map,
+ relationships: ImmutablePropTypes.map,
+ }
+
+ getRole() {
+ const { intl, relationships } = this.props;
+
+ if (!relationships) return null;
+ if (relationships.get('admin')) return intl.formatMessage(messages.role_admin);
+ if (relationships.get('member')) return intl.formatMessage(messages.role_member);
+ }
+
+ render() {
+ const { intl, group } = this.props;
+ const coverImageUrl = group.get('cover_image_url');
+ const role = this.getRole();
+
+ return (
+
+ {coverImageUrl &&
![]({coverImageUrl})
}
+
+
{group.get('title')}
+
{shortNumberFormat(group.get('member_count'))} {intl.formatMessage(messages.members)}{role && ยท {role}}
+
{group.get('description')}
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/gabsocial/features/groups/index/index.js b/app/javascript/gabsocial/features/groups/index/index.js
index 16dbf949..176e5ef5 100644
--- a/app/javascript/gabsocial/features/groups/index/index.js
+++ b/app/javascript/gabsocial/features/groups/index/index.js
@@ -2,77 +2,88 @@ import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
-import LoadingIndicator from '../../../components/loading_indicator';
-import Column from '../../ui/components/column';
import { fetchGroups } from '../../../actions/groups';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import ColumnLink from '../../ui/components/column_link';
-import ColumnSubheading from '../../ui/components/column_subheading';
-import NewGroupForm from '../create';
-import { createSelector } from 'reselect';
-import ScrollableList from '../../../components/scrollable_list';
+import { Link } from 'react-router-dom';
+import classNames from 'classnames';
+import GroupCard from './card';
+import GroupCreate from '../create';
const messages = defineMessages({
- heading: { id: 'column.groups', defaultMessage: 'Groups' },
- subheading: { id: 'groups.subheading', defaultMessage: 'Your groups' },
+ heading: { id: 'column.groups', defaultMessage: 'Groups' },
+ create: { id: 'groups.create', defaultMessage: 'Create group' },
+ tab_featured: { id: 'groups.tab_featured', defaultMessage: 'Featured' },
+ tab_member: { id: 'groups.tab_member', defaultMessage: 'Groups you\'re in' },
+ tab_admin: { id: 'groups.tab_admin', defaultMessage: 'Groups you manage' },
});
-const getOrderedGroups = createSelector([state => state.get('groups')], groups => {
- if (!groups) {
- return groups;
- }
-
- return groups.toList().filter(item => !!item);
-});
-
-const mapStateToProps = state => ({
- groups: getOrderedGroups(state),
+const mapStateToProps = (state, { activeTab }) => ({
+ groupIds: state.getIn(['group_lists', activeTab]),
});
export default @connect(mapStateToProps)
@injectIntl
class Groups extends ImmutablePureComponent {
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ activeTab: PropTypes.string.isRequired,
+ showCreateForm: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ groups: ImmutablePropTypes.map,
+ groupIds: ImmutablePropTypes.list,
+ intl: PropTypes.object.isRequired,
+ };
- static propTypes = {
- params: PropTypes.object.isRequired,
- dispatch: PropTypes.func.isRequired,
- groups: ImmutablePropTypes.list,
- intl: PropTypes.object.isRequired,
- };
+ componentWillMount () {
+ this.props.dispatch(fetchGroups(this.props.activeTab));
+ }
- componentWillMount () {
- this.props.dispatch(fetchGroups());
- }
+ componentDidUpdate(oldProps) {
+ if (this.props.activeTab && this.props.activeTab !== oldProps.activeTab) {
+ this.props.dispatch(fetchGroups(this.props.activeTab));
+ }
+ }
- render () {
- const { intl, groups } = this.props;
+ renderHeader() {
+ const { intl, activeTab } = this.props;
- if (!groups) {
- return (
-
-
-
- );
- }
+ return (
+
+
{intl.formatMessage(messages.create)}
+
{intl.formatMessage(messages.heading)}
- const emptyMessage =
;
+
+
+
+ {intl.formatMessage(messages.tab_featured)}
+
- return (
-
-
+
+ {intl.formatMessage(messages.tab_member)}
+
-
-
- {groups.map(group =>
-
- )}
-
-
- );
- }
+
+ {intl.formatMessage(messages.tab_admin)}
+
+
+
+
+ );
+ }
-}
+ render () {
+ const { groupIds, showCreateForm } = this.props;
+
+ return (
+
+ {!showCreateForm && this.renderHeader()}
+ {showCreateForm &&
}
+
+
+ {groupIds.map(id => )}
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/gabsocial/features/groups/members/index.js b/app/javascript/gabsocial/features/groups/members/index.js
new file mode 100644
index 00000000..ed396d4f
--- /dev/null
+++ b/app/javascript/gabsocial/features/groups/members/index.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../../components/loading_indicator';
+import {
+ fetchMembers,
+ expandMembers,
+} from '../../../actions/groups';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from '../../../containers/account_container';
+import Column from '../../ui/components/column';
+import ScrollableList from '../../../components/scrollable_list';
+
+const mapStateToProps = (state, { params: { id } }) => ({
+ group: state.getIn(['groups', id]),
+ accountIds: state.getIn(['user_lists', 'groups', id, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'groups', id, 'next']),
+});
+
+export default @connect(mapStateToProps)
+class GroupMembers extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ const { params: { id } } = this.props;
+
+ this.props.dispatch(fetchMembers(id));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.id !== this.props.params.id) {
+ this.props.dispatch(fetchMembers(nextProps.params.id));
+ }
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandMembers(this.props.params.id));
+ }, 300, { leading: true });
+
+ render () {
+ const { accountIds, hasMore, group } = this.props;
+
+ if (!group || !accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ >
+ {accountIds.map(id => )}
+
+
+ );
+ }
+}
diff --git a/app/javascript/gabsocial/features/groups/removed_accounts/index.js b/app/javascript/gabsocial/features/groups/removed_accounts/index.js
new file mode 100644
index 00000000..c8ad0e08
--- /dev/null
+++ b/app/javascript/gabsocial/features/groups/removed_accounts/index.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../../components/loading_indicator';
+import {
+ fetchRemovedAccounts,
+ expandRemovedAccounts,
+ removeRemovedAccount,
+} from '../../../actions/groups';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from '../../../containers/account_container';
+import Column from '../../ui/components/column';
+import ScrollableList from '../../../components/scrollable_list';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+ remove: { id: 'groups.removed_accounts', defaultMessage: 'Allow joining' },
+});
+
+const mapStateToProps = (state, { params: { id } }) => ({
+ group: state.getIn(['groups', id]),
+ accountIds: state.getIn(['user_lists', 'groups_removed_accounts', id, 'items']),
+ hasMore: !!state.getIn(['user_lists', 'groups_removed_accounts', id, 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class GroupRemovedAccounts extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ hasMore: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ const { params: { id } } = this.props;
+
+ this.props.dispatch(fetchRemovedAccounts(id));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.id !== this.props.params.id) {
+ this.props.dispatch(fetchRemovedAccounts(nextProps.params.id));
+ }
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandRemovedAccounts(this.props.params.id));
+ }, 300, { leading: true });
+
+ render () {
+ const { accountIds, hasMore, group, intl } = this.props;
+
+ if (!group || !accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ }
+ >
+ {accountIds.map(id => this.props.dispatch(removeRemovedAccount(group.get('id'), id))}
+ actionTitle={intl.formatMessage(messages.remove)}
+ />)}
+
+
+ );
+ }
+}
diff --git a/app/javascript/gabsocial/features/groups/sidebar_panel/index.js b/app/javascript/gabsocial/features/groups/sidebar_panel/index.js
new file mode 100644
index 00000000..88e74928
--- /dev/null
+++ b/app/javascript/gabsocial/features/groups/sidebar_panel/index.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import Item from './item';
+import Icon from 'gabsocial/components/icon';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+ title: { id: 'groups.sidebar-panel.title', defaultMessage: 'Groups you\'re in' },
+ show_all: { id: 'groups.sidebar-panel.show_all', defaultMessage: 'Show all' },
+});
+
+const mapStateToProps = (state, { id }) => ({
+ groupIds: state.getIn(['group_lists', 'member']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class GroupSidebarPanel extends ImmutablePureComponent {
+ static propTypes = {
+ groupIds: ImmutablePropTypes.list,
+ }
+
+ render() {
+ const { intl, groupIds } = this.props;
+ const count = groupIds.count();
+
+ // Only when there are groups to show
+ if (count === 0) return null;
+
+ return (
+
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+
+ {groupIds.slice(0, 10).map(groupId => )}
+ {count > 10 && {intl.formatMessage(messages.show_all)}}
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/gabsocial/features/groups/sidebar_panel/item.js b/app/javascript/gabsocial/features/groups/sidebar_panel/item.js
new file mode 100644
index 00000000..a4015c18
--- /dev/null
+++ b/app/javascript/gabsocial/features/groups/sidebar_panel/item.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { shortNumberFormat } from '../../../utils/numbers';
+import { connect } from 'react-redux';
+
+const messages = defineMessages({
+ new_statuses: { id: 'groups.sidebar-panel.item.view', defaultMessage: 'new gabs' },
+ no_recent_activity: { id: 'groups.sidebar-panel.item.no_recent_activity', defaultMessage: 'No recent activity' },
+});
+
+const mapStateToProps = (state, { id }) => ({
+ group: state.getIn(['groups', id]),
+ relationships: state.getIn(['group_relationships', id]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Item extends ImmutablePureComponent {
+ static propTypes = {
+ group: ImmutablePropTypes.map,
+ relationships: ImmutablePropTypes.map,
+ }
+
+ render() {
+ const { intl, group, relationships } = this.props;
+
+ // Wait for relationships
+ if (!relationships) return null;
+
+ const unreadCount = relationships.get('unread_count');
+
+ return (
+
+ {group.get('title')}
+
+ {unreadCount > 0 && {shortNumberFormat(unreadCount)} {intl.formatMessage(messages.new_statuses)}}
+ {unreadCount === 0 && {intl.formatMessage(messages.no_recent_activity)}}
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/gabsocial/features/groups/timeline/components/header.js b/app/javascript/gabsocial/features/groups/timeline/components/header.js
index d053bc22..9691fca3 100644
--- a/app/javascript/gabsocial/features/groups/timeline/components/header.js
+++ b/app/javascript/gabsocial/features/groups/timeline/components/header.js
@@ -1,44 +1,77 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
-import InnerHeader from './inner_header';
+import Button from 'gabsocial/components/button';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import { FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
import { NavLink } from 'react-router-dom';
+import DropdownMenuContainer from '../../../../containers/dropdown_menu_container';
-export default class Header extends ImmutablePureComponent {
+const messages = defineMessages({
+ join: { id: 'groups.join', defaultMessage: 'Join group' },
+ leave: { id: 'groups.leave', defaultMessage: 'Leave group' },
+ removed_accounts: { id: 'groups.removed_accounts', defaultMessage: 'Removed Accounts' },
+ edit: { id: 'groups.edit', defaultMessage: 'Edit' }
+});
- static propTypes = {
- group: ImmutablePropTypes.map,
- relationships: ImmutablePropTypes.map,
- toggleMembership: PropTypes.func.isRequired,
- };
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+ static propTypes = {
+ group: ImmutablePropTypes.map,
+ relationships: ImmutablePropTypes.map,
+ toggleMembership: PropTypes.func.isRequired,
+ };
- static contextTypes = {
- router: PropTypes.object,
- };
+ static contextTypes = {
+ router: PropTypes.object,
+ };
- render () {
- const { group, relationships, toggleMembership } = this.props;
+ getActionButton() {
+ const { group, relationships, toggleMembership, intl } = this.props;
+ const toggle = () => toggleMembership(group, relationships);
- if (group === null) {
- return null;
- }
+ if (!relationships) {
+ return '';
+ } else if (!relationships.get('member')) {
+ return ;
+ } else if (relationships.get('member')) {
+ return ;
+ }
+ }
- return (
-
-
+ getAdminMenu() {
+ const { group, intl } = this.props;
-
-
-
-
-
- );
- }
+ const menu = [
+ { text: intl.formatMessage(messages.edit), to: `/groups/${group.get('id')}/edit` },
+ { text: intl.formatMessage(messages.removed_accounts), to: `/groups/${group.get('id')}/removed_accounts` },
+ ];
+ return ;
+ }
+
+ render () {
+ const { group, relationships } = this.props;
+
+ if (!group || !relationships) {
+ return null;
+ }
+
+ return (
+
+
+
+
![]({group.get('cover_image_url')})
+
+
+
+ Posts
+ Members
+ {this.getActionButton()}
+ {relationships.get('admin') && this.getAdminMenu()}
+
+
+
+ );
+ }
}
diff --git a/app/javascript/gabsocial/features/groups/timeline/components/inner_header.js b/app/javascript/gabsocial/features/groups/timeline/components/inner_header.js
deleted file mode 100644
index f87b0da8..00000000
--- a/app/javascript/gabsocial/features/groups/timeline/components/inner_header.js
+++ /dev/null
@@ -1,96 +0,0 @@
-'use strict';
-
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Button from 'gabsocial/components/button';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Icon from 'gabsocial/components/icon';
-import DropdownMenuContainer from 'gabsocial/containers/dropdown_menu_container';
-
-const messages = defineMessages({
- join: { id: 'groups.join', defaultMessage: 'Join' },
- leave: { id: 'groups.leave', defaultMessage: 'Leave' },
-});
-
-export default @injectIntl
-class InnerHeader extends ImmutablePureComponent {
-
- static propTypes = {
- group: ImmutablePropTypes.map,
- relationships: ImmutablePropTypes.map,
- toggleMembership: PropTypes.func.isRequired,
- };
-
- isStatusesPageActive = (match, location) => {
- if (!match) {
- return false;
- }
-
- return !location.pathname.match(/\/(accounts)\/?$/);
- }
-
- render () {
- const { group, relationships, intl } = this.props;
-
- if (!group || !relationships) {
- return null;
- }
-
- let info = [];
- let actionBtn = '';
- let lockedIcon = '';
- let menu = [];
-
- if (relationships.get('admin')) {
- info.push();
- }
-
- if (!relationships) { // Wait until the relationship is loaded
- actionBtn = '';
- } else if (!relationships.get('member')) {
- actionBtn =