From 1fabd284984e07ead5e3c3c25077a1518b53a01b Mon Sep 17 00:00:00 2001 From: 2458773093 <2458773093@protonmail.com> Date: Mon, 15 Jul 2019 16:47:05 +0300 Subject: [PATCH 01/41] New groups --- .../api/v1/groups/accounts_controller.rb | 2 +- app/controllers/api/v1/groups_controller.rb | 16 +- .../api/v1/timelines/group_controller.rb | 4 +- app/javascript/gabsocial/actions/compose.js | 3 +- app/javascript/gabsocial/actions/groups.js | 114 ++++++++++++- .../compose/components/compose_form.js | 3 +- .../containers/compose_form_container.js | 4 +- .../gabsocial/features/groups/index/card.js | 54 +++++++ .../gabsocial/features/groups/index/index.js | 121 +++++++------- .../features/groups/members/index.js | 73 +++++++++ .../groups/timeline/components/header.js | 78 +++++---- .../timeline/components/inner_header.js | 96 ----------- .../groups/timeline/components/panel.js | 34 ++++ .../features/groups/timeline/index.js | 150 +++++++++--------- .../features/ui/components/tabs_bar.js | 3 + app/javascript/gabsocial/features/ui/index.js | 10 +- .../features/ui/util/async-components.js | 4 + app/javascript/gabsocial/locales/en.json | 1 + app/javascript/gabsocial/pages/group_page.js | 71 +++++++++ app/javascript/gabsocial/pages/groups_page.js | 53 +++++++ .../gabsocial/reducers/group_lists.js | 21 +++ app/javascript/gabsocial/reducers/index.js | 2 + .../gabsocial/reducers/user_lists.js | 9 ++ app/javascript/styles/application.scss | 2 + .../gabsocial/components/group-card.scss | 71 +++++++++ .../gabsocial/components/group-detail.scss | 72 +++++++++ .../styles/gabsocial/components/tabs-bar.scss | 2 + app/serializers/rest/group_serializer.rb | 16 +- app/services/fan_out_on_write_service.rb | 2 +- app/services/group_query_service.rb | 3 +- 30 files changed, 809 insertions(+), 285 deletions(-) create mode 100644 app/javascript/gabsocial/features/groups/index/card.js create mode 100644 app/javascript/gabsocial/features/groups/members/index.js delete mode 100644 app/javascript/gabsocial/features/groups/timeline/components/inner_header.js create mode 100644 app/javascript/gabsocial/features/groups/timeline/components/panel.js create mode 100644 app/javascript/gabsocial/pages/group_page.js create mode 100644 app/javascript/gabsocial/pages/groups_page.js create mode 100644 app/javascript/gabsocial/reducers/group_lists.js create mode 100644 app/javascript/styles/gabsocial/components/group-card.scss create mode 100644 app/javascript/styles/gabsocial/components/group-detail.scss 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_controller.rb b/app/controllers/api/v1/groups_controller.rb index 8299f788..13f7748e 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 }).all + when 'admin' + @groups = Group.joins(:group_accounts).where(is_archived: false, group_accounts: { account: current_account, write_permissions: true }).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..cbcfb44d 100644 --- a/app/controllers/api/v1/timelines/group_controller.rb +++ b/app/controllers/api/v1/timelines/group_controller.rb @@ -29,7 +29,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController end def group_statuses - statuses = tag_timeline_statuses.paginate_by_id( + statuses = group_timeline_statuses.paginate_by_id( limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id) ) @@ -43,7 +43,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/javascript/gabsocial/actions/compose.js b/app/javascript/gabsocial/actions/compose.js index 34fd54f6..fa995182 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/groups.js b/app/javascript/gabsocial/actions/groups.js index 0de668b8..b67a3116 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,14 @@ 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 fetchGroup = id => (dispatch, getState) => { if (!me) return; @@ -98,13 +108,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 +125,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 +205,93 @@ 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, + }; +}; \ No newline at end of file diff --git a/app/javascript/gabsocial/features/compose/components/compose_form.js b/app/javascript/gabsocial/features/compose/components/compose_form.js index 6d8625e5..5d6a677f 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, }; static defaultProps = { @@ -118,7 +119,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 8395d589..15157b9e 100644 --- a/app/javascript/gabsocial/features/compose/containers/compose_form_container.js +++ b/app/javascript/gabsocial/features/compose/containers/compose_form_container.js @@ -33,8 +33,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/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 743a72e2..a582d3ae 100644 --- a/app/javascript/gabsocial/features/groups/index/index.js +++ b/app/javascript/gabsocial/features/groups/index/index.js @@ -2,80 +2,75 @@ 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 ColumnBackButtonSlim from '../../../components/column_back_button_slim'; 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'; const messages = defineMessages({ - heading: { id: 'column.groups', defaultMessage: 'Groups' }, - subheading: { id: 'groups.subheading', defaultMessage: 'Your groups' }, + heading: { id: 'column.groups', defaultMessage: 'Groups' }, + tab_featured: { id: 'column.groups_tab_featured', defaultMessage: 'Featured' }, + tab_member: { id: 'column.groups_tab_member', defaultMessage: 'Groups you\'re in' }, + tab_admin: { id: 'column.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, + 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; - - if (!groups) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - - - - - - {groups.map(group => - - )} - - - ); - } - -} + render () { + const { intl, groupIds, activeTab } = this.props; + + return ( +
+
+
{intl.formatMessage(messages.heading)}
+ +
+

+ + {intl.formatMessage(messages.tab_featured)} + + + + {intl.formatMessage(messages.tab_member)} + + + + {intl.formatMessage(messages.tab_admin)} + +

+
+
+ +
+ {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/timeline/components/header.js b/app/javascript/gabsocial/features/groups/timeline/components/header.js index d053bc22..9944cb87 100644 --- a/app/javascript/gabsocial/features/groups/timeline/components/header.js +++ b/app/javascript/gabsocial/features/groups/timeline/components/header.js @@ -1,44 +1,62 @@ 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'; -export default class Header extends ImmutablePureComponent { +const messages = defineMessages({ + join: { id: 'groups.join', defaultMessage: 'Join group' }, + leave: { id: 'groups.leave', defaultMessage: 'Leave group' }, +}); - 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 + ); } diff --git a/app/javascript/gabsocial/features/groups/edit/index.js b/app/javascript/gabsocial/features/groups/edit/index.js index 7f55a824..a58e4b5d 100644 --- a/app/javascript/gabsocial/features/groups/edit/index.js +++ b/app/javascript/gabsocial/features/groups/edit/index.js @@ -3,16 +3,17 @@ 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 IconButton from '../../../components/icon_button'; +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({ - heading: { id: 'groups.edit.heading', defaultMessage: 'Edit group' }, title: { id: 'groups.form.title', defaultMessage: 'Title' }, description: { id: 'groups.form.description', defaultMessage: 'Description' }, - coverImage: { id: 'groups.form.coverImage', defaultMessage: 'Cover Image' }, + 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' }, }); @@ -102,45 +103,42 @@ class Edit extends React.PureComponent { } return ( -
-

{intl.formatMessage(messages.heading)}

- -