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 ( -
- + render () { + const { title, description, coverImage, disabled, intl } = this.props; - - - ); - } + 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 && }
+
+
{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