Merge branch 'groups-updates' of https://code.gab.com/gab/social/gab-social into develop

This commit is contained in:
Rob Colbert
2019-07-21 23:08:31 -04:00
75 changed files with 2201 additions and 417 deletions

View File

@@ -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']),

View File

@@ -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,
});

View File

@@ -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,
};
};

View File

@@ -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;

View File

@@ -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
/>
))

View File

@@ -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));

View File

@@ -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 = () => {

View File

@@ -34,8 +34,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeCompose(text));
},
onSubmit (router) {
dispatch(submitCompose(router));
onSubmit (router, group) {
dispatch(submitCompose(router, group));
},
onClearSuggestions () {

View File

@@ -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 (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit(this.context.router.history);
}
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
render () {
const { title, description, coverImage, disabled, intl } = this.props;
<IconButton
disabled={disabled}
icon='plus'
title={title}
onClick={this.handleClick}
/>
</form>
);
}
return (
<form className='group-form' onSubmit={this.handleSubmit}>
<div>
<input
className='group-form__input'
value={title}
disabled={disabled}
onChange={this.handleTitleChange}
placeholder={intl.formatMessage(messages.title)}
/>
</div>
<div>
<input
className='group-form__input'
value={description}
disabled={disabled}
onChange={this.handleDescriptionChange}
placeholder={intl.formatMessage(messages.description)}
/>
</div>
<div>
<label htmlFor='group_cover_image' className={classNames('group-form__file-label', { 'group-form__file-label--selected': coverImage !== null })}>
<Icon id='camera' /> {intl.formatMessage(coverImage === null ? messages.coverImage : messages.coverImageChange)}
</label>
<input
type='file'
className='group-form__file'
id='group_cover_image'
disabled={disabled}
onChange={this.handleCoverImageChange}
/>
<button className='standard-small'>{intl.formatMessage(messages.create)}</button>
</div>
</form>
);
}
}

View File

@@ -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 (
<Column>
<LoadingIndicator />
</Column>
);
} else if (group === false) {
return (
<Column>
<MissingIndicator />
</Column>
);
}
return (
<form className='group-form' onSubmit={this.handleSubmit}>
<div>
<input
className='group-form__input'
value={title}
disabled={disabled}
onChange={this.handleTitleChange}
placeholder={intl.formatMessage(messages.title)}
/>
</div>
<div>
<input
className='group-form__input'
value={description}
disabled={disabled}
onChange={this.handleDescriptionChange}
placeholder={intl.formatMessage(messages.description)}
/>
</div>
<div>
<label htmlFor='group_cover_image' className={classNames('group-form__file-label', { 'group-form__file-label--selected': coverImage !== null })}>
<Icon id='camera' /> {intl.formatMessage(coverImage === null ? messages.coverImage : messages.coverImageChange)}
</label>
<input
type='file'
className='group-form__file'
id='group_cover_image'
disabled={disabled}
onChange={this.handleCoverImageChange}
/>
<button>{intl.formatMessage(messages.update)}</button>
</div>
</form>
);
}
}

View File

@@ -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 (
<Link to={`/groups/${group.get('id')}`} className="group-card">
<div className="group-card__header">{coverImageUrl && <img alt="" src={coverImageUrl} />}</div>
<div className="group-card__content">
<div className="group-card__title">{group.get('title')}</div>
<div className="group-card__meta"><strong>{shortNumberFormat(group.get('member_count'))}</strong> {intl.formatMessage(messages.members)}{role && <span> · {role}</span>}</div>
<div className="group-card__description">{group.get('description')}</div>
</div>
</Link>
);
}
}

View File

@@ -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 (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<div className="group-column-header">
<div className="group-column-header__cta"><Link to="/groups/create" className="button standard-small">{intl.formatMessage(messages.create)}</Link></div>
<div className="group-column-header__title">{intl.formatMessage(messages.heading)}</div>
const emptyMessage = <FormattedMessage id='empty_column.groups' defaultMessage="No groups." />;
<div className="column-header__wrapper">
<h1 className="column-header">
<Link to='/groups' className={classNames('btn grouped', {'active': 'featured' === activeTab})}>
{intl.formatMessage(messages.tab_featured)}
</Link>
return (
<Column icon='list-ul' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<NewGroupForm />
<Link to='/groups/browse/member' className={classNames('btn grouped', {'active': 'member' === activeTab})}>
{intl.formatMessage(messages.tab_member)}
</Link>
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
>
{groups.map(group =>
<ColumnLink key={group.get('id')} to={`/groups/${group.get('id')}`} icon='list-ul' text={group.get('title')} />
)}
</ScrollableList>
</Column>
);
}
<Link to='/groups/browse/admin' className={classNames('btn grouped', {'active': 'admin' === activeTab})}>
{intl.formatMessage(messages.tab_admin)}
</Link>
</h1>
</div>
</div>
);
}
}
render () {
const { groupIds, showCreateForm } = this.props;
return (
<div>
{!showCreateForm && this.renderHeader()}
{showCreateForm && <GroupCreate /> }
<div className="group-card-list">
{groupIds.map(id => <GroupCard key={id} id={id} />)}
</div>
</div>
);
}
}

View File

@@ -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 (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ScrollableList
scrollKey='members'
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='group.members.empty' defaultMessage='This group does not has any members.' />}
>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</ScrollableList>
</Column>
);
}
}

View File

@@ -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 (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ScrollableList
scrollKey='removed_accounts'
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='group.removed_accounts.empty' defaultMessage='This group does not has any removed accounts.' />}
>
{accountIds.map(id => <AccountContainer
key={id}
id={id}
actionIcon="remove"
onActionClick={() => this.props.dispatch(removeRemovedAccount(group.get('id'), id))}
actionTitle={intl.formatMessage(messages.remove)}
/>)}
</ScrollableList>
</Column>
);
}
}

View File

@@ -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 (
<div className='wtf-panel group-sidebar-panel'>
<div className='wtf-panel-header'>
<Icon id='users' className='wtf-panel-header__icon' />
<span className='wtf-panel-header__label'>{intl.formatMessage(messages.title)}</span>
</div>
<div className='wtf-panel__content'>
<div className="group-sidebar-panel__items">
{groupIds.slice(0, 10).map(groupId => <Item key={groupId} id={groupId} />)}
{count > 10 && <Link className="group-sidebar-panel__items__show-all" to='/groups/browse/member'>{intl.formatMessage(messages.show_all)}</Link>}
</div>
</div>
</div>
);
}
}

View File

@@ -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 (
<Link to={`/groups/${group.get('id')}`} className="group-sidebar-panel__item">
<div className="group-sidebar-panel__item__title">{group.get('title')}</div>
<div className="group-sidebar-panel__item__meta">
{unreadCount > 0 && <span className="group-sidebar-panel__item__meta__unread">{shortNumberFormat(unreadCount)} {intl.formatMessage(messages.new_statuses)}</span>}
{unreadCount === 0 && <span>{intl.formatMessage(messages.no_recent_activity)}</span>}
</div>
</Link>
);
}
}

View File

@@ -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 <Button className='logo-button' text={intl.formatMessage(messages.join)} onClick={toggle} />;
} else if (relationships.get('member')) {
return <Button className='logo-button' text={intl.formatMessage(messages.leave, { name: group.get('title') })} onClick={toggle} />;
}
}
return (
<div className='account-timeline__header'>
<InnerHeader
group={group}
relationships={relationships}
toggleMembership={toggleMembership}
/>
getAdminMenu() {
const { group, intl } = this.props;
<div className='account__section-headline'>
<NavLink exact to={`/groups/${group.get('id')}`}><FormattedMessage id='groups.posts' defaultMessage='Posts' /></NavLink>
<NavLink exact to={`/groups/${group.get('id')}/accounts`}><FormattedMessage id='group.accounts' defaultMessage='Members' /></NavLink>
</div>
</div>
);
}
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 <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />;
}
render () {
const { group, relationships } = this.props;
if (!group || !relationships) {
return null;
}
return (
<div className='group__header-container'>
<div className="group__header">
<div className='group__cover'>
<img src={group.get('cover_image_url')} alt='' className='parallax' />
</div>
<div className='group__tabs'>
<NavLink exact className='group__tabs__tab' activeClassName='group__tabs__tab--active' to={`/groups/${group.get('id')}`}>Posts</NavLink>
<NavLink exact className='group__tabs__tab' activeClassName='group__tabs__tab--active' to={`/groups/${group.get('id')}/members`}>Members</NavLink>
{this.getActionButton()}
{relationships.get('admin') && this.getAdminMenu()}
</div>
</div>
</div>
);
}
}

View File

@@ -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(<span key='admin'><FormattedMessage id='group.admin' defaultMessage='You are an admin' /></span>);
}
if (!relationships) { // Wait until the relationship is loaded
actionBtn = '';
} else if (!relationships.get('member')) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.join)} onClick={() => this.props.toggleMembership(group, relationships)} />;
} else if (relationships.get('member')) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.leave, { name: group.get('title') })} onClick={() => this.props.toggleMembership(group, relationships)} />;
}
if (group.get('archived')) {
lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.group_archived)} />;
}
return (
<div className='account__header'>
<div className='account__header__image'>
<div className='account__header__info'>
<img src={group.get('cover_image_url')} alt='' className='parallax' />
</div>
</div>
<div className='account__header__bar'>
<div className='account__header__tabs'>
<div className='account__header__tabs__buttons'>
{actionBtn}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div>
</div>
<div className='account__header__tabs__name'>
<h1>
<span>{group.get('title')} {info}</span>
<small>{lockedIcon}</small>
</h1>
</div>
<div className='account__header__extra'>
<div className='account__header__bio'>
{group.get('description').length > 0 && <div className='account__header__content'>{group.get('description')}</div>}
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
group_archived: { id: 'group.detail.archived_group', defaultMessage: 'Archived group' },
group_admin: { id: 'groups.detail.role_admin', defaultMessage: 'You\'re an admin' }
});
export default @injectIntl
class GroupPanel extends ImmutablePureComponent {
static propTypes = {
group: ImmutablePropTypes.map,
relationships: ImmutablePropTypes.map,
}
render() {
const { group, relationships, intl } = this.props;
return (
<div className="group__panel">
<h1 className="group__panel__title">
{group.get('title')}
{group.get('archived') && <Icon id='lock' title={intl.formatMessage(messages.group_archived)} />}
</h1>
{relationships.get('admin') && <span className="group__panel__label">{intl.formatMessage(messages.group_admin)}</span>}
<div className="group__panel__description">{group.get('description')}</div>
</div>
);
}
}

View File

@@ -4,99 +4,101 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusListContainer from '../../ui/containers/status_list_container';
import Column from '../../../components/column';
import ColumnHeader from '../../../components/column_header';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connectGroupStream } from '../../../actions/streaming';
import { expandGroupTimeline } from '../../../actions/timelines';
import { fetchGroup } from '../../../actions/groups';
import MissingIndicator from '../../../components/missing_indicator';
import LoadingIndicator from '../../../components/loading_indicator';
import HeaderContainer from './containers/header_container';
import ComposeFormContainer from '../../../../gabsocial/features/compose/containers/compose_form_container';
import { me } from 'gabsocial/initial_state';
import Avatar from '../../../components/avatar';
const mapStateToProps = (state, props) => ({
group: state.getIn(['groups', props.params.id]),
hasUnread: state.getIn(['timelines', `group:${props.params.id}`, 'unread']) > 0,
account: state.getIn(['accounts', me]),
group: state.getIn(['groups', props.params.id]),
relationships: state.getIn(['group_relationships', props.params.id]),
hasUnread: state.getIn(['timelines', `group:${props.params.id}`, 'unread']) > 0,
});
export default @connect(mapStateToProps)
@injectIntl
class GroupTimeline extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
hasUnread: PropTypes.bool,
group: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
relationships: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
hasUnread: PropTypes.bool,
group: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
};
componentDidMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
componentDidMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(expandGroupTimeline(id));
dispatch(fetchGroup(id));
dispatch(expandGroupTimeline(id));
this.disconnect = dispatch(connectGroupStream(id));
}
this.disconnect = dispatch(connectGroupStream(id));
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
handleLoadMore = maxId => {
const { id } = this.props.params;
this.props.dispatch(expandGroupTimeline(id, { maxId }));
}
handleLoadMore = maxId => {
const { id } = this.props.params;
this.props.dispatch(expandGroupTimeline(id, { maxId }));
}
render () {
const { columnId, group, relationships, account } = this.props;
const { id } = this.props.params;
render () {
const { hasUnread, group } = this.props;
const { id } = this.props.params;
const title = group ? group.get('title') : id;
if (typeof group === 'undefined' || !relationships) {
return (
<Column>
<LoadingIndicator />
</Column>
);
} else if (group === false) {
return (
<Column>
<MissingIndicator />
</Column>
);
}
if (typeof group === 'undefined') {
return (
<Column>
<div>
<LoadingIndicator />
</div>
</Column>
);
} else if (group === false) {
return (
<Column>
<MissingIndicator />
</Column>
);
}
return (
<Column label={title}>
<ColumnHeader icon='list-ul' active={hasUnread} title={title}>
<div className='column-header__links'>
{/* Leave might be here */}
</div>
<hr />
</ColumnHeader>
<StatusListContainer
prepend={<HeaderContainer groupId={id} />}
alwaysPrepend
scrollKey='group_timeline'
timelineId={`group:${id}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is nothing in this group yet. When members of this group post new statuses, they will appear here.' />}
/>
</Column>
);
}
return (
<div>
{relationships.get('member') && (
<div className='timeline-compose-block'>
<div className='timeline-compose-block__avatar'>
<Avatar account={account} size={46} />
</div>
<ComposeFormContainer group={group} shouldCondense={true} autoFocus={false}/>
</div>
)}
<div className='group__feed'>
<StatusListContainer
alwaysPrepend
scrollKey={`group_timeline-${columnId}`}
timelineId={`group:${id}`}
onLoadMore={this.handleLoadMore}
withGroupAdmin={relationships && relationships.get('admin')}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is nothing in this group yet. When members of this group post new statuses, they will appear here.' />}
/>
</div>
</div>
);
}
}

View File

@@ -24,9 +24,9 @@ export const privateLinks = [
<NotificationsCounterIcon />
<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />
</NavLink>,
// <NavLink className='tabs-bar__link groups' to='/groups' data-preview-title-id='column.groups' >
// <FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />
// </NavLink>,
<NavLink className='tabs-bar__link groups' to='/groups' data-preview-title-id='column.groups' >
<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />
</NavLink>,
<NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' >
<FormattedMessage id='tabs_bar.search' defaultMessage='Search' />
</NavLink>,

View File

@@ -25,8 +25,11 @@ import TabsBar from './components/tabs_bar';
import WhoToFollowPanel from './components/who_to_follow_panel';
import LinkFooter from './components/link_footer';
import ProfilePage from 'gabsocial/pages/profile_page';
import GroupsPage from 'gabsocial/pages/groups_page';
import GroupPage from 'gabsocial/pages/group_page';
import SearchPage from 'gabsocial/pages/search_page';
import HomePage from 'gabsocial/pages/home_page';
import GroupSidebarPanel from '../groups/sidebar_panel';
import {
Status,
@@ -55,6 +58,10 @@ import {
GroupTimeline,
ListTimeline,
Lists,
GroupMembers,
GroupRemovedAccounts,
GroupCreate,
GroupEdit,
} from './util/async-components';
import { me, meUsername } from '../../initial_state';
import { previewState as previewMediaState } from './components/media_modal';
@@ -63,6 +70,7 @@ import { previewState as previewVideoState } from './components/video_modal';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import '../../components/status';
import { fetchGroups } from '../../actions/groups';
const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Gab Social.' },
@@ -116,12 +124,14 @@ const LAYOUT = {
],
RIGHT: [
// <TrendsPanel />,
<GroupSidebarPanel />
],
},
STATUS: {
TOP: null,
LEFT: null,
RIGHT: [
<GroupSidebarPanel />,
<WhoToFollowPanel key='0' />,
// <TrendsPanel />,
<LinkFooter key='1' />,
@@ -174,8 +184,14 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/home' exact page={HomePage} component={HomeTimeline} content={children} />
<WrappedRoute path='/timeline/all' exact page={HomePage} component={CommunityTimeline} content={children} />
<WrappedRoute path='/groups' component={Groups} content={children} />
<WrappedRoute path='/groups/:id' component={GroupTimeline} content={children} />
<WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'featured' }} />
<WrappedRoute path='/groups/create' page={GroupsPage} component={Groups} content={children} componentParams={{ showCreateForm: true, activeTab: 'featured' }} />
<WrappedRoute path='/groups/browse/member' page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'member' }} />
<WrappedRoute path='/groups/browse/admin' page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'admin' }} />
<WrappedRoute path='/groups/:id/members' page={GroupPage} component={GroupMembers} content={children} />
<WrappedRoute path='/groups/:id/removed_accounts' page={GroupPage} component={GroupRemovedAccounts} content={children} />
<WrappedRoute path='/groups/:id/edit' page={GroupPage} component={GroupEdit} content={children} />
<WrappedRoute path='/groups/:id' page={GroupPage} component={GroupTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
@@ -360,6 +376,7 @@ class UI extends React.PureComponent {
if (me) {
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
this.props.dispatch(fetchGroups('member'));
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
}

View File

@@ -34,6 +34,22 @@ export function GroupTimeline () {
return import(/* webpackChunkName: "features/groups/timeline" */'../../groups/timeline');
}
export function GroupMembers () {
return import(/* webpackChunkName: "features/groups/timeline" */'../../groups/members');
}
export function GroupRemovedAccounts () {
return import(/* webpackChunkName: "features/groups/timeline" */'../../groups/removed_accounts');
}
export function GroupCreate () {
return import(/* webpackChunkName: "features/groups/timeline" */'../../groups/create');
}
export function GroupEdit () {
return import(/* webpackChunkName: "features/groups/timeline" */'../../groups/edit');
}
export function Groups () {
return import(/* webpackChunkName: "features/groups/index" */'../../groups/index');
}

View File

@@ -54,6 +54,7 @@
"column.lists": "Lists",
"column.mutes": "Muted users",
"column.notifications": "Notifications",
"column.groups": "Groups",
"column.pins": "Pinned gabs",
"column.public": "Federated timeline",
"column_back_button.label": "Back",

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { connect } from 'react-redux';
import { PropTypes } from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import WhoToFollowPanel from '../features/ui/components/who_to_follow_panel';
import LinkFooter from '../features/ui/components/link_footer';
import PromoPanel from '../features/ui/components/promo_panel';
import HeaderContainer from '../features/groups/timeline/containers/header_container';
import GroupPanel from '../features/groups/timeline/components/panel';
import { fetchGroup } from '../actions/groups';
import GroupSidebarPanel from '../features/groups/sidebar_panel';
const mapStateToProps = (state, { params: { id } }) => ({
group: state.getIn(['groups', id]),
relationships: state.getIn(['group_relationships', id]),
});
export default @connect(mapStateToProps)
class GroupPage extends ImmutablePureComponent {
static propTypes = {
group: ImmutablePropTypes.map,
relationships: ImmutablePropTypes.map,
dispatch: PropTypes.func.isRequired,
};
componentWillMount() {
const { params: { id }, dispatch } = this.props;
dispatch(fetchGroup(id));
}
render () {
const { children, group, relationships } = this.props;
return (
<div className='page group'>
{group && <HeaderContainer groupId={group.get('id')} />}
<div className='page__columns'>
<div className='columns-area__panels'>
<div className='columns-area__panels__pane columns-area__panels__pane--left'>
<div className='columns-area__panels__pane__inner'>
{group && relationships &&
<GroupPanel
group={group}
relationships={relationships}
/>}
<PromoPanel />
<LinkFooter />
</div>
</div>
<div className='columns-area__panels__main'>
<div className='columns-area columns-area--mobile'>
{children}
</div>
</div>
<div className='columns-area__panels__pane columns-area__panels__pane--right'>
<div className='columns-area__panels__pane__inner'>
<GroupSidebarPanel />
<WhoToFollowPanel />
</div>
</div>
</div>
</div>
</div>
)
}
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { connect } from 'react-redux';
import { me } from 'gabsocial/initial_state';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import WhoToFollowPanel from '../features/ui/components/who_to_follow_panel';
import LinkFooter from '../features/ui/components/link_footer';
import PromoPanel from '../features/ui/components/promo_panel';
import UserPanel from '../features/ui/components/user_panel';
import GroupSidebarPanel from '../features/groups/sidebar_panel';
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
});
export default @connect(mapStateToProps)
class GroupsPage extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map
};
render () {
const { children } = this.props;
return (
<div className='page'>
<div className='page__columns'>
<div className='columns-area__panels'>
<div className='columns-area__panels__pane columns-area__panels__pane--left'>
<div className='columns-area__panels__pane__inner'>
<UserPanel />
<PromoPanel />
<LinkFooter />
</div>
</div>
<div className='columns-area__panels__main'>
<div className='columns-area columns-area--mobile'>
{children}
</div>
</div>
<div className='columns-area__panels__pane columns-area__panels__pane--right'>
<div className='columns-area__panels__pane__inner'>
<WhoToFollowPanel />
<GroupSidebarPanel />
</div>
</div>
</div>
</div>
</div>
)
}
}

View File

@@ -9,6 +9,7 @@ import PromoPanel from '../features/ui/components/promo_panel';
import UserPanel from '../features/ui/components/user_panel';
import ComposeFormContainer from '../features/compose/containers/compose_form_container';
import Avatar from '../components/avatar';
import GroupSidebarPanel from '../features/groups/sidebar_panel';
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
@@ -48,6 +49,7 @@ class HomePage extends ImmutablePureComponent {
<div className='columns-area__panels__pane columns-area__panels__pane--right'>
<div className='columns-area__panels__pane__inner'>
<WhoToFollowPanel />
<GroupSidebarPanel />
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { Map as ImmutableMap } from 'immutable';
import {
GROUP_CREATE_REQUEST,
GROUP_CREATE_FAIL,
GROUP_CREATE_SUCCESS,
GROUP_UPDATE_REQUEST,
GROUP_UPDATE_FAIL,
GROUP_UPDATE_SUCCESS,
GROUP_EDITOR_RESET,
GROUP_EDITOR_SETUP,
GROUP_EDITOR_VALUE_CHANGE,
} from '../actions/group_editor';
const initialState = ImmutableMap({
groupId: null,
isSubmitting: false,
isChanged: false,
title: '',
description: '',
coverImage: null,
});
export default function groupEditorReducer(state = initialState, action) {
switch(action.type) {
case GROUP_EDITOR_RESET:
return initialState;
case GROUP_EDITOR_SETUP:
return state.withMutations(map => {
map.set('groupId', action.group.get('id'));
map.set('title', action.group.get('title'));
map.set('description', action.group.get('description'));
map.set('isSubmitting', false);
});
case GROUP_EDITOR_VALUE_CHANGE:
return state.withMutations(map => {
map.set(action.field, action.value);
map.set('isChanged', true);
});
case GROUP_CREATE_REQUEST:
case GROUP_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case GROUP_CREATE_FAIL:
case GROUP_UPDATE_FAIL:
return state.set('isSubmitting', false);
case GROUP_CREATE_SUCCESS:
case GROUP_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('groupId', action.group.id);
});
default:
return state;
}
};

View File

@@ -0,0 +1,21 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { GROUPS_FETCH_SUCCESS } from '../actions/groups';
const initialState = ImmutableMap({
featured: ImmutableList(),
member: ImmutableList(),
admin: ImmutableList(),
});
const normalizeList = (state, type, id, groups) => {
return state.set(type, ImmutableList(groups.map(item => item.id)));
};
export default function groupLists(state = initialState, action) {
switch(action.type) {
case GROUPS_FETCH_SUCCESS:
return normalizeList(state, action.tab, action.id, action.groups);
default:
return state;
}
};

View File

@@ -3,6 +3,7 @@ import {
GROUP_FETCH_FAIL,
GROUPS_FETCH_SUCCESS,
} from '../actions/groups';
import { GROUP_UPDATE_SUCCESS } from '../actions/group_editor';
import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap();
@@ -20,6 +21,7 @@ const normalizeGroups = (state, groups) => {
export default function groups(state = initialState, action) {
switch(action.type) {
case GROUP_FETCH_SUCCESS:
case GROUP_UPDATE_SUCCESS:
return normalizeGroup(state, action.group);
case GROUPS_FETCH_SUCCESS:
return normalizeGroups(state, action.groups);

View File

@@ -34,6 +34,8 @@ import identity_proofs from './identity_proofs';
import trends from './trends';
import groups from './groups';
import group_relationships from './group_relationships';
import group_lists from './group_lists';
import group_editor from './group_editor';
const reducers = {
dropdown_menu,
@@ -71,6 +73,8 @@ const reducers = {
trends,
groups,
group_relationships,
group_lists,
group_editor,
};
export default combineReducers(reducers);

View File

@@ -19,6 +19,7 @@ import {
} from '../actions/accounts';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import compareId from '../compare_id';
import { GROUP_REMOVE_STATUS_SUCCESS } from '../actions/groups';
const initialState = ImmutableMap();
@@ -151,6 +152,10 @@ const filterTimeline = (timeline, state, relationship, statuses) =>
statuses.getIn([statusId, 'account']) === relationship.id
));
const removeStatusFromGroup = (state, groupId, statusId) => {
return state.updateIn([`group:${groupId}`, 'items'], list => list.filterNot(item => item === statusId));
};
export default function timelines(state = initialState, action) {
switch(action.type) {
case TIMELINE_EXPAND_REQUEST:
@@ -187,6 +192,8 @@ export default function timelines(state = initialState, action) {
initialTimeline,
map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items)
);
case GROUP_REMOVE_STATUS_SUCCESS:
return removeStatusFromGroup(state, action.groupId, action.id)
default:
return state;
}

View File

@@ -21,6 +21,13 @@ import {
MUTES_EXPAND_SUCCESS,
} from '../actions/mutes';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
GROUP_MEMBERS_FETCH_SUCCESS,
GROUP_MEMBERS_EXPAND_SUCCESS,
GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS,
GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS,
GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS,
} from '../actions/groups';
const initialState = ImmutableMap({
followers: ImmutableMap(),
@@ -30,6 +37,8 @@ const initialState = ImmutableMap({
follow_requests: ImmutableMap(),
blocks: ImmutableMap(),
mutes: ImmutableMap(),
groups: ImmutableMap(),
groups_removed_accounts: ImmutableMap(),
});
const normalizeList = (state, type, id, accounts, next) => {
@@ -74,6 +83,16 @@ export default function userLists(state = initialState, action) {
return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case MUTES_EXPAND_SUCCESS:
return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
case GROUP_MEMBERS_FETCH_SUCCESS:
return normalizeList(state, 'groups', action.id, action.accounts, action.next);
case GROUP_MEMBERS_EXPAND_SUCCESS:
return appendToList(state, 'groups', action.id, action.accounts, action.next);
case GROUP_REMOVED_ACCOUNTS_FETCH_SUCCESS:
return normalizeList(state, 'groups_removed_accounts', action.id, action.accounts, action.next);
case GROUP_REMOVED_ACCOUNTS_EXPAND_SUCCESS:
return appendToList(state, 'groups_removed_accounts', action.id, action.accounts, action.next);
case GROUP_REMOVED_ACCOUNTS_REMOVE_SUCCESS:
return state.updateIn(['groups_removed_accounts', action.groupId, 'items'], list => list.filterNot(item => item === action.id));
default:
return state;
}