Merge branch 'groups-updates' of https://code.gab.com/gab/social/gab-social into develop
This commit is contained in:
		
						commit
						46bec5710f
					
				| @ -9,7 +9,7 @@ class Api::V1::Groups::AccountsController < Api::BaseController | |||||||
|   before_action :require_user! |   before_action :require_user! | ||||||
|   before_action :set_group |   before_action :set_group | ||||||
| 
 | 
 | ||||||
|   after_action :insert_pagination_headers, only: :index |   after_action :insert_pagination_headers, only: :show | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|     @accounts = load_accounts |     @accounts = load_accounts | ||||||
|  | |||||||
							
								
								
									
										90
									
								
								app/controllers/api/v1/groups/removed_accounts_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								app/controllers/api/v1/groups/removed_accounts_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||||
| @ -10,10 +10,24 @@ class Api::V1::GroupsController < Api::BaseController | |||||||
|   before_action :set_group, except: [:index, :create] |   before_action :set_group, except: [:index, :create] | ||||||
| 
 | 
 | ||||||
|   def index |   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 |     render json: @groups, each_serializer: REST::GroupSerializer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def current_tab  | ||||||
|  |     tab = 'featured' | ||||||
|  |     tab = params[:tab] if ['featured', 'member', 'admin'].include? params[:tab] | ||||||
|  |     return tab | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def show |   def show | ||||||
|     render json: @group, serializer: REST::GroupSerializer |     render json: @group, serializer: REST::GroupSerializer | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -9,6 +9,8 @@ class Api::V1::Timelines::GroupController < Api::BaseController | |||||||
|   after_action :insert_pagination_headers, unless: -> { @statuses.empty? } |   after_action :insert_pagination_headers, unless: -> { @statuses.empty? } | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|  |     mark_as_unread | ||||||
|  | 
 | ||||||
|     render json: @statuses, |     render json: @statuses, | ||||||
|            each_serializer: REST::StatusSerializer, |            each_serializer: REST::StatusSerializer, | ||||||
|            relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) |            relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) | ||||||
| @ -16,6 +18,10 @@ class Api::V1::Timelines::GroupController < Api::BaseController | |||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|  |   def mark_as_unread | ||||||
|  |     GroupAccount.where(group: @group, account: current_account).update_all("unread_count = 0") | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def set_group |   def set_group | ||||||
|     @group = Group.find(params[:id]) |     @group = Group.find(params[:id]) | ||||||
|   end |   end | ||||||
| @ -29,7 +35,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def group_statuses |   def group_statuses | ||||||
|     statuses = tag_timeline_statuses.paginate_by_id( |     statuses = group_timeline_statuses.without_replies.paginate_by_id( | ||||||
|       limit_param(DEFAULT_STATUSES_LIMIT), |       limit_param(DEFAULT_STATUSES_LIMIT), | ||||||
|       params_slice(:max_id, :since_id, :min_id) |       params_slice(:max_id, :since_id, :min_id) | ||||||
|     ) |     ) | ||||||
| @ -43,7 +49,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def group_statuses |   def group_timeline_statuses | ||||||
|     GroupQueryService.new.call(@group) |     GroupQueryService.new.call(@group) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -50,6 +50,7 @@ class Settings::PreferencesController < Settings::BaseController | |||||||
|       :setting_aggregate_reblogs, |       :setting_aggregate_reblogs, | ||||||
|       :setting_show_application, |       :setting_show_application, | ||||||
|       :setting_advanced_layout, |       :setting_advanced_layout, | ||||||
|  |       :setting_group_in_home_feed, | ||||||
|       notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), |       notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), | ||||||
|       interactions: %i(must_be_follower must_be_following) |       interactions: %i(must_be_follower must_be_following) | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -125,7 +125,7 @@ export function directCompose(account, routerHistory) { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function submitCompose(routerHistory) { | export function submitCompose(routerHistory, group) { | ||||||
|   return function (dispatch, getState) { |   return function (dispatch, getState) { | ||||||
|     if (!me) return; |     if (!me) return; | ||||||
| 
 | 
 | ||||||
| @ -147,6 +147,7 @@ export function submitCompose(routerHistory) { | |||||||
|       spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), |       spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), | ||||||
|       visibility: getState().getIn(['compose', 'privacy']), |       visibility: getState().getIn(['compose', 'privacy']), | ||||||
|       poll: getState().getIn(['compose', 'poll'], null), |       poll: getState().getIn(['compose', 'poll'], null), | ||||||
|  |       group_id: group ? group.get('id') : null, | ||||||
|     }, { |     }, { | ||||||
|       headers: { |       headers: { | ||||||
|         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), |         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), | ||||||
|  | |||||||
							
								
								
									
										113
									
								
								app/javascript/gabsocial/actions/group_editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								app/javascript/gabsocial/actions/group_editor.js
									
									
									
									
									
										Normal 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, | ||||||
|  | }); | ||||||
| @ -1,5 +1,7 @@ | |||||||
| import api from '../api'; | import api, { getLinks } from '../api'; | ||||||
| import { me } from 'gabsocial/initial_state'; | 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_REQUEST = 'GROUP_FETCH_REQUEST'; | ||||||
| export const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS'; | 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_SUCCESS = 'GROUP_LEAVE_SUCCESS'; | ||||||
| export const GROUP_LEAVE_FAIL    = 'GROUP_LEAVE_FAIL'; | 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) => { | export const fetchGroup = id => (dispatch, getState) => { | ||||||
|   if (!me) return; |   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; |   if (!me) return; | ||||||
| 
 | 
 | ||||||
|   dispatch(fetchGroupsRequest()); |   dispatch(fetchGroupsRequest()); | ||||||
| 
 | 
 | ||||||
|   api(getState).get('/api/v1/groups') |   api(getState).get('/api/v1/groups?tab=' + tab) | ||||||
|     .then(({ data }) => dispatch(fetchGroupsSuccess(data))) |     .then(({ data }) => { | ||||||
|  |       dispatch(fetchGroupsSuccess(data, tab)); | ||||||
|  |       dispatch(fetchGroupRelationships(data.map(item => item.id))); | ||||||
|  |     }) | ||||||
|     .catch(err => dispatch(fetchGroupsFail(err))); |     .catch(err => dispatch(fetchGroupsFail(err))); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| @ -112,9 +145,10 @@ export const fetchGroupsRequest = () => ({ | |||||||
|   type: GROUPS_FETCH_REQUEST, |   type: GROUPS_FETCH_REQUEST, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export const fetchGroupsSuccess = groups => ({ | export const fetchGroupsSuccess = (groups, tab) => ({ | ||||||
|   type: GROUPS_FETCH_SUCCESS, |   type: GROUPS_FETCH_SUCCESS, | ||||||
|   groups, |   groups, | ||||||
|  |   tab, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export const fetchGroupsFail = error => ({ | export const fetchGroupsFail = error => ({ | ||||||
| @ -191,3 +225,300 @@ export function leaveGroupFail(error) { | |||||||
|     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, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @ -1,3 +1,4 @@ | |||||||
|  | 
 | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | 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_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' }, |   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, | ||||||
|   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, |   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 { | class StatusActionBar extends ImmutablePureComponent { | ||||||
| @ -59,6 +62,7 @@ class StatusActionBar extends ImmutablePureComponent { | |||||||
|     onMuteConversation: PropTypes.func, |     onMuteConversation: PropTypes.func, | ||||||
|     onPin: PropTypes.func, |     onPin: PropTypes.func, | ||||||
|     withDismiss: PropTypes.bool, |     withDismiss: PropTypes.bool, | ||||||
|  |     withGroupAdmin: PropTypes.bool, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
| @ -165,8 +169,20 @@ class StatusActionBar extends ImmutablePureComponent { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|    |    | ||||||
|  |   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) => { |   _makeMenu = (publicStatus) => { | ||||||
|     const { status, intl, withDismiss } = this.props; |     const { status, intl, withDismiss, withGroupAdmin } = this.props; | ||||||
|     const mutingConversation = status.get('muted'); |     const mutingConversation = status.get('muted'); | ||||||
| 
 | 
 | ||||||
|     let menu = []; |     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_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')}` }); |         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; |     return menu; | ||||||
|  | |||||||
| @ -25,6 +25,7 @@ export default class StatusList extends ImmutablePureComponent { | |||||||
|     timelineId: PropTypes.string, |     timelineId: PropTypes.string, | ||||||
|     queuedItemSize: PropTypes.number, |     queuedItemSize: PropTypes.number, | ||||||
|     onDequeueTimeline: PropTypes.func, |     onDequeueTimeline: PropTypes.func, | ||||||
|  |     withGroupAdmin: PropTypes.bool, | ||||||
|     onScrollToTop: PropTypes.func, |     onScrollToTop: PropTypes.func, | ||||||
|     onScroll: PropTypes.func, |     onScroll: PropTypes.func, | ||||||
|   }; |   }; | ||||||
| @ -84,7 +85,7 @@ export default class StatusList extends ImmutablePureComponent { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   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) { |     if (isPartial) { | ||||||
|       return ( |       return ( | ||||||
| @ -114,6 +115,7 @@ export default class StatusList extends ImmutablePureComponent { | |||||||
|           onMoveUp={this.handleMoveUp} |           onMoveUp={this.handleMoveUp} | ||||||
|           onMoveDown={this.handleMoveDown} |           onMoveDown={this.handleMoveDown} | ||||||
|           contextType={timelineId} |           contextType={timelineId} | ||||||
|  |           withGroupAdmin={withGroupAdmin} | ||||||
|           showThread |           showThread | ||||||
|         /> |         /> | ||||||
|       )) |       )) | ||||||
|  | |||||||
| @ -29,6 +29,10 @@ import { openModal } from '../actions/modal'; | |||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import { boostModal, deleteModal } from '../initial_state'; | import { boostModal, deleteModal } from '../initial_state'; | ||||||
| import { showAlertForError } from '../actions/alerts'; | import { showAlertForError } from '../actions/alerts'; | ||||||
|  | import {  | ||||||
|  |   createRemovedAccount, | ||||||
|  |   groupRemoveStatus | ||||||
|  | } from '../actions/groups'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, |   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)); | export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | ||||||
|  | |||||||
| @ -68,6 +68,7 @@ class ComposeForm extends ImmutablePureComponent { | |||||||
|     anyMedia: PropTypes.bool, |     anyMedia: PropTypes.bool, | ||||||
|     shouldCondense: PropTypes.bool, |     shouldCondense: PropTypes.bool, | ||||||
|     autoFocus: PropTypes.bool, |     autoFocus: PropTypes.bool, | ||||||
|  |     group: ImmutablePropTypes.map, | ||||||
|     isModalOpen: PropTypes.bool, |     isModalOpen: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
| @ -119,7 +120,7 @@ class ComposeForm extends ImmutablePureComponent { | |||||||
|       return; |       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 = () => { |   onSuggestionsClearRequested = () => { | ||||||
|  | |||||||
| @ -34,8 +34,8 @@ const mapDispatchToProps = (dispatch) => ({ | |||||||
|     dispatch(changeCompose(text)); |     dispatch(changeCompose(text)); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   onSubmit (router) { |   onSubmit (router, group) { | ||||||
|     dispatch(submitCompose(router)); |     dispatch(submitCompose(router, group)); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   onClearSuggestions () { |   onClearSuggestions () { | ||||||
|  | |||||||
| @ -1,76 +1,109 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { changeListEditorTitle, submitListEditor } from '../../../actions/lists'; | import { changeValue, submit, reset } from '../../../actions/group_editor'; | ||||||
| import IconButton from '../../../components/icon_button'; | import Icon from '../../../components/icon'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | import classNames from 'classnames'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   label: { id: 'groups.new.title_placeholder', defaultMessage: 'New group title' }, | 	title: { id: 'groups.form.title', defaultMessage: 'Enter a new group title' }, | ||||||
|   title: { id: 'groups.new.create', defaultMessage: 'Add group' }, | 	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 => ({ | const mapStateToProps = state => ({ | ||||||
|   value: state.getIn(['groupEditor', 'title']), | 	title: state.getIn(['group_editor', 'title']), | ||||||
|   disabled: state.getIn(['groupEditor', 'isSubmitting']), | 	description: state.getIn(['group_editor', 'description']), | ||||||
|  | 	coverImage: state.getIn(['group_editor', 'coverImage']), | ||||||
|  | 	disabled: state.getIn(['group_editor', 'isSubmitting']), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const mapDispatchToProps = dispatch => ({ | const mapDispatchToProps = dispatch => ({ | ||||||
|   onChange: value => dispatch(changeListEditorTitle(value)), | 	onTitleChange: value => dispatch(changeValue('title', value)), | ||||||
|   onSubmit: () => dispatch(submitListEditor(true)), | 	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) | export default @connect(mapStateToProps, mapDispatchToProps) | ||||||
| @injectIntl | @injectIntl | ||||||
| class Create extends React.PureComponent { | class Create extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|  | 	static contextTypes = { | ||||||
|  | 		router: PropTypes.object | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	static propTypes = { | 	static propTypes = { | ||||||
|     value: PropTypes.string.isRequired, | 		title: PropTypes.string.isRequired, | ||||||
|  | 		description: PropTypes.string.isRequired, | ||||||
|  | 		coverImage: PropTypes.object, | ||||||
| 		disabled: PropTypes.bool, | 		disabled: PropTypes.bool, | ||||||
| 		intl: PropTypes.object.isRequired, | 		intl: PropTypes.object.isRequired, | ||||||
|     onChange: PropTypes.func.isRequired, | 		onTitleChange: PropTypes.func.isRequired, | ||||||
| 		onSubmit: PropTypes.func.isRequired, | 		onSubmit: PropTypes.func.isRequired, | ||||||
| 	}; | 	}; | ||||||
| 
 | 
 | ||||||
|   handleChange = e => { | 	componentWillMount() { | ||||||
|     this.props.onChange(e.target.value); | 		this.props.reset(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	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 => { | 	handleSubmit = e => { | ||||||
| 		e.preventDefault(); | 		e.preventDefault(); | ||||||
|     this.props.onSubmit(); | 		this.props.onSubmit(this.context.router.history); | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   handleClick = () => { |  | ||||||
|     this.props.onSubmit(); |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	render () { | 	render () { | ||||||
|     const { value, disabled, intl } = this.props; | 		const { title, description, coverImage, disabled, intl } = this.props; | ||||||
| 
 |  | ||||||
|     const label = intl.formatMessage(messages.label); |  | ||||||
|     const title = intl.formatMessage(messages.title); |  | ||||||
| 
 | 
 | ||||||
| 		return ( | 		return ( | ||||||
|       <form className='column-inline-form' onSubmit={this.handleSubmit}> | 			<form className='group-form' onSubmit={this.handleSubmit}> | ||||||
|         <label> | 				<div> | ||||||
|           <span style={{ display: 'none' }}>{label}</span> |  | ||||||
| 
 |  | ||||||
| 					<input | 					<input | ||||||
|             className='setting-text' | 						className='group-form__input' | ||||||
|             value={value} | 						value={title} | ||||||
| 						disabled={disabled} | 						disabled={disabled} | ||||||
|             onChange={this.handleChange} | 						onChange={this.handleTitleChange} | ||||||
|             placeholder={label} | 						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> | 					</label> | ||||||
| 
 | 					<input | ||||||
|         <IconButton | 						type='file' | ||||||
|  | 						className='group-form__file' | ||||||
|  | 						id='group_cover_image' | ||||||
| 						disabled={disabled} | 						disabled={disabled} | ||||||
|           icon='plus' | 						onChange={this.handleCoverImageChange} | ||||||
|           title={title} |  | ||||||
|           onClick={this.handleClick} |  | ||||||
| 					/> | 					/> | ||||||
|  | 					<button className='standard-small'>{intl.formatMessage(messages.create)}</button> | ||||||
|  | 				</div> | ||||||
| 			</form> | 			</form> | ||||||
| 		); | 		); | ||||||
| 	} | 	} | ||||||
|  | |||||||
							
								
								
									
										146
									
								
								app/javascript/gabsocial/features/groups/edit/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								app/javascript/gabsocial/features/groups/edit/index.js
									
									
									
									
									
										Normal 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> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										54
									
								
								app/javascript/gabsocial/features/groups/index/card.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								app/javascript/gabsocial/features/groups/index/card.js
									
									
									
									
									
										Normal 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> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -2,77 +2,88 @@ import React from 'react'; | |||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import LoadingIndicator from '../../../components/loading_indicator'; |  | ||||||
| import Column from '../../ui/components/column'; |  | ||||||
| import { fetchGroups } from '../../../actions/groups'; | 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 ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import ColumnLink from '../../ui/components/column_link'; | import { Link } from 'react-router-dom'; | ||||||
| import ColumnSubheading from '../../ui/components/column_subheading'; | import classNames from 'classnames'; | ||||||
| import NewGroupForm from '../create'; | import GroupCard from './card'; | ||||||
| import { createSelector } from 'reselect'; | import GroupCreate from '../create'; | ||||||
| import ScrollableList from '../../../components/scrollable_list'; |  | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
| 	heading: { id: 'column.groups', defaultMessage: 'Groups' }, | 	heading: { id: 'column.groups', defaultMessage: 'Groups' }, | ||||||
|   subheading: { id: 'groups.subheading', defaultMessage: 'Your 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 => { | const mapStateToProps = (state, { activeTab }) => ({ | ||||||
|   if (!groups) { | 	groupIds: state.getIn(['group_lists', activeTab]), | ||||||
|     return groups; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return groups.toList().filter(item => !!item); |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| const mapStateToProps = state => ({ |  | ||||||
|   groups: getOrderedGroups(state), |  | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default @connect(mapStateToProps) | export default @connect(mapStateToProps) | ||||||
| @injectIntl | @injectIntl | ||||||
| class Groups extends ImmutablePureComponent { | class Groups extends ImmutablePureComponent { | ||||||
| 
 |  | ||||||
| 	static propTypes = { | 	static propTypes = { | ||||||
| 		params: PropTypes.object.isRequired, | 		params: PropTypes.object.isRequired, | ||||||
|  | 		activeTab: PropTypes.string.isRequired, | ||||||
|  | 		showCreateForm: PropTypes.bool, | ||||||
| 		dispatch: PropTypes.func.isRequired, | 		dispatch: PropTypes.func.isRequired, | ||||||
|     groups: ImmutablePropTypes.list, | 		groups: ImmutablePropTypes.map, | ||||||
|  | 		groupIds: ImmutablePropTypes.list, | ||||||
| 		intl: PropTypes.object.isRequired, | 		intl: PropTypes.object.isRequired, | ||||||
| 	}; | 	}; | ||||||
| 
 | 
 | ||||||
| 	componentWillMount () { | 	componentWillMount () { | ||||||
|     this.props.dispatch(fetchGroups()); | 		this.props.dispatch(fetchGroups(this.props.activeTab)); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	componentDidUpdate(oldProps) { | ||||||
|  | 		if (this.props.activeTab && this.props.activeTab !== oldProps.activeTab) { | ||||||
|  | 			this.props.dispatch(fetchGroups(this.props.activeTab)); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	renderHeader() { | ||||||
|  | 		const { intl, activeTab } = this.props; | ||||||
|  | 
 | ||||||
|  | 		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> | ||||||
|  | 
 | ||||||
|  | 				<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> | ||||||
|  | 
 | ||||||
|  | 						<Link to='/groups/browse/member' className={classNames('btn grouped', {'active': 'member' === activeTab})}> | ||||||
|  | 							{intl.formatMessage(messages.tab_member)} | ||||||
|  | 						</Link> | ||||||
|  | 
 | ||||||
|  | 						<Link to='/groups/browse/admin' className={classNames('btn grouped', {'active': 'admin' === activeTab})}> | ||||||
|  | 							{intl.formatMessage(messages.tab_admin)} | ||||||
|  | 						</Link> | ||||||
|  | 					</h1> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	render () { | 	render () { | ||||||
|     const { intl, groups } = this.props; | 		const { groupIds, showCreateForm } = this.props; | ||||||
| 
 |  | ||||||
|     if (!groups) { |  | ||||||
|       return ( |  | ||||||
|         <Column> |  | ||||||
|           <LoadingIndicator /> |  | ||||||
|         </Column> |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const emptyMessage = <FormattedMessage id='empty_column.groups' defaultMessage="No groups." />; |  | ||||||
| 
 | 
 | ||||||
| 		return ( | 		return ( | ||||||
|       <Column icon='list-ul' heading={intl.formatMessage(messages.heading)} backBtnSlim> | 			<div> | ||||||
|         <NewGroupForm /> | 				{!showCreateForm && this.renderHeader()} | ||||||
|  | 				{showCreateForm && <GroupCreate /> } | ||||||
| 
 | 
 | ||||||
|         <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> | 				<div className="group-card-list"> | ||||||
|         <ScrollableList | 					{groupIds.map(id => <GroupCard key={id} id={id} />)} | ||||||
|           scrollKey='lists' | 				</div> | ||||||
|           emptyMessage={emptyMessage} | 			</div> | ||||||
|         > |  | ||||||
|           {groups.map(group => |  | ||||||
|             <ColumnLink key={group.get('id')} to={`/groups/${group.get('id')}`} icon='list-ul' text={group.get('title')} /> |  | ||||||
|           )} |  | ||||||
|         </ScrollableList> |  | ||||||
|       </Column> |  | ||||||
| 		); | 		); | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| } | } | ||||||
							
								
								
									
										73
									
								
								app/javascript/gabsocial/features/groups/members/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								app/javascript/gabsocial/features/groups/members/index.js
									
									
									
									
									
										Normal 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> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @ -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> | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @ -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> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -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> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -1,13 +1,21 @@ | |||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import InnerHeader from './inner_header'; | import Button from 'gabsocial/components/button'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | 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 { 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' } | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
|  | export default @injectIntl | ||||||
|  | class Header extends ImmutablePureComponent { | ||||||
| 	static propTypes = { | 	static propTypes = { | ||||||
| 		group: ImmutablePropTypes.map, | 		group: ImmutablePropTypes.map, | ||||||
| 		relationships: ImmutablePropTypes.map, | 		relationships: ImmutablePropTypes.map, | ||||||
| @ -18,27 +26,52 @@ export default class Header extends ImmutablePureComponent { | |||||||
| 		router: PropTypes.object, | 		router: PropTypes.object, | ||||||
| 	}; | 	}; | ||||||
| 
 | 
 | ||||||
|   render () { | 	getActionButton() { | ||||||
|     const { group, relationships, toggleMembership } = this.props; | 		const { group, relationships, toggleMembership, intl } = this.props; | ||||||
|  | 		const toggle = () => toggleMembership(group, relationships); | ||||||
| 
 | 
 | ||||||
|     if (group === 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} />; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	getAdminMenu() { | ||||||
|  | 		const { group, intl } = this.props; | ||||||
|  | 
 | ||||||
|  | 		const menu = [ | ||||||
|  | 			{ text: intl.formatMessage(messages.edit), to: `/groups/${group.get('id')}/edit` }, | ||||||
|  | 			{ text: intl.formatMessage(messages.removed_accounts), to: `/groups/${group.get('id')}/removed_accounts` }, | ||||||
|  | 		]; | ||||||
|  | 
 | ||||||
|  | 		return <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	render () { | ||||||
|  | 		const { group, relationships } = this.props; | ||||||
|  | 
 | ||||||
|  | 		if (!group || !relationships) { | ||||||
| 			return null; | 			return null; | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		return ( | 		return ( | ||||||
|       <div className='account-timeline__header'> | 			<div className='group__header-container'> | ||||||
|         <InnerHeader | 				<div className="group__header"> | ||||||
|           group={group} | 					<div className='group__cover'> | ||||||
|           relationships={relationships} | 						<img src={group.get('cover_image_url')} alt='' className='parallax' /> | ||||||
|           toggleMembership={toggleMembership} | 					</div> | ||||||
|         /> |  | ||||||
| 					 | 					 | ||||||
|         <div className='account__section-headline'> | 					<div className='group__tabs'> | ||||||
|             <NavLink exact to={`/groups/${group.get('id')}`}><FormattedMessage id='groups.posts' defaultMessage='Posts' /></NavLink> | 						<NavLink exact className='group__tabs__tab' activeClassName='group__tabs__tab--active' to={`/groups/${group.get('id')}`}>Posts</NavLink> | ||||||
|             <NavLink exact to={`/groups/${group.get('id')}/accounts`}><FormattedMessage id='group.accounts' defaultMessage='Members' /></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> | ||||||
| 			</div> | 			</div> | ||||||
| 		); | 		); | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -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> |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
| @ -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> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -4,24 +4,25 @@ import PropTypes from 'prop-types'; | |||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import StatusListContainer from '../../ui/containers/status_list_container'; | import StatusListContainer from '../../ui/containers/status_list_container'; | ||||||
| import Column from '../../../components/column'; | import Column from '../../../components/column'; | ||||||
| import ColumnHeader from '../../../components/column_header'; |  | ||||||
| import { FormattedMessage, injectIntl } from 'react-intl'; | import { FormattedMessage, injectIntl } from 'react-intl'; | ||||||
| import { connectGroupStream } from '../../../actions/streaming'; | import { connectGroupStream } from '../../../actions/streaming'; | ||||||
| import { expandGroupTimeline } from '../../../actions/timelines'; | import { expandGroupTimeline } from '../../../actions/timelines'; | ||||||
| import { fetchGroup } from '../../../actions/groups'; |  | ||||||
| import MissingIndicator from '../../../components/missing_indicator'; | import MissingIndicator from '../../../components/missing_indicator'; | ||||||
| import LoadingIndicator from '../../../components/loading_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) => ({ | const mapStateToProps = (state, props) => ({ | ||||||
|  | 	account: state.getIn(['accounts', me]), | ||||||
| 	group: state.getIn(['groups', props.params.id]), | 	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, | 	hasUnread: state.getIn(['timelines', `group:${props.params.id}`, 'unread']) > 0, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default @connect(mapStateToProps) | export default @connect(mapStateToProps) | ||||||
| @injectIntl | @injectIntl | ||||||
| class GroupTimeline extends React.PureComponent { | class GroupTimeline extends React.PureComponent { | ||||||
| 
 |  | ||||||
| 	static contextTypes = { | 	static contextTypes = { | ||||||
| 		router: PropTypes.object, | 		router: PropTypes.object, | ||||||
| 	}; | 	}; | ||||||
| @ -29,8 +30,11 @@ class GroupTimeline extends React.PureComponent { | |||||||
| 	static propTypes = { | 	static propTypes = { | ||||||
| 		params: PropTypes.object.isRequired, | 		params: PropTypes.object.isRequired, | ||||||
| 		dispatch: PropTypes.func.isRequired, | 		dispatch: PropTypes.func.isRequired, | ||||||
|  | 		columnId: PropTypes.string, | ||||||
| 		hasUnread: PropTypes.bool, | 		hasUnread: PropTypes.bool, | ||||||
| 		group: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), | 		group: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), | ||||||
|  | 		relationships: ImmutablePropTypes.map, | ||||||
|  | 		account: ImmutablePropTypes.map, | ||||||
| 		intl: PropTypes.object.isRequired, | 		intl: PropTypes.object.isRequired, | ||||||
| 	}; | 	}; | ||||||
| 
 | 
 | ||||||
| @ -38,7 +42,6 @@ class GroupTimeline extends React.PureComponent { | |||||||
| 		const { dispatch } = this.props; | 		const { dispatch } = this.props; | ||||||
| 		const { id } = this.props.params; | 		const { id } = this.props.params; | ||||||
| 
 | 
 | ||||||
|     dispatch(fetchGroup(id)); |  | ||||||
| 		dispatch(expandGroupTimeline(id)); | 		dispatch(expandGroupTimeline(id)); | ||||||
| 
 | 
 | ||||||
| 		this.disconnect = dispatch(connectGroupStream(id)); | 		this.disconnect = dispatch(connectGroupStream(id)); | ||||||
| @ -57,16 +60,13 @@ class GroupTimeline extends React.PureComponent { | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	render () { | 	render () { | ||||||
|     const { hasUnread, group } = this.props; | 		const { columnId, group, relationships, account } = this.props; | ||||||
| 		const { id } = this.props.params; | 		const { id } = this.props.params; | ||||||
|     const title  = group ? group.get('title') : id; |  | ||||||
| 
 | 
 | ||||||
|     if (typeof group === 'undefined') { | 		if (typeof group === 'undefined' || !relationships) { | ||||||
| 			return ( | 			return ( | ||||||
| 				<Column> | 				<Column> | ||||||
|           <div> |  | ||||||
| 					<LoadingIndicator /> | 					<LoadingIndicator /> | ||||||
|           </div> |  | ||||||
| 				</Column> | 				</Column> | ||||||
| 			); | 			); | ||||||
| 		} else if (group === false) { | 		} else if (group === false) { | ||||||
| @ -78,25 +78,27 @@ class GroupTimeline extends React.PureComponent { | |||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		return ( | 		return ( | ||||||
|       <Column label={title}> | 			<div> | ||||||
|         <ColumnHeader icon='list-ul' active={hasUnread} title={title}> | 				{relationships.get('member') && ( | ||||||
|           <div className='column-header__links'> | 					<div className='timeline-compose-block'> | ||||||
|             {/* Leave might be here */} | 						<div className='timeline-compose-block__avatar'> | ||||||
|  | 							<Avatar account={account} size={46} /> | ||||||
| 						</div> | 						</div> | ||||||
|  | 						<ComposeFormContainer group={group} shouldCondense={true} autoFocus={false}/> | ||||||
|  | 					</div> | ||||||
|  | 				)} | ||||||
| 
 | 
 | ||||||
|           <hr /> | 				<div className='group__feed'> | ||||||
|         </ColumnHeader> |  | ||||||
| 
 |  | ||||||
| 					<StatusListContainer | 					<StatusListContainer | ||||||
|           prepend={<HeaderContainer groupId={id} />} |  | ||||||
| 						alwaysPrepend | 						alwaysPrepend | ||||||
|           scrollKey='group_timeline' | 						scrollKey={`group_timeline-${columnId}`} | ||||||
| 						timelineId={`group:${id}`} | 						timelineId={`group:${id}`} | ||||||
| 						onLoadMore={this.handleLoadMore} | 						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.' />} | 						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> | 				</div> | ||||||
|  | 			</div> | ||||||
| 		); | 		); | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -24,9 +24,9 @@ export const privateLinks = [ | |||||||
|     <NotificationsCounterIcon /> |     <NotificationsCounterIcon /> | ||||||
|     <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /> |     <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /> | ||||||
|   </NavLink>, |   </NavLink>, | ||||||
|     //  <NavLink className='tabs-bar__link groups' to='/groups' data-preview-title-id='column.groups' >
 |   <NavLink className='tabs-bar__link groups' to='/groups' data-preview-title-id='column.groups' > | ||||||
|     //    <FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />
 |     <FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' /> | ||||||
|     //  </NavLink>,
 |   </NavLink>, | ||||||
|   <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' > |   <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' > | ||||||
|     <FormattedMessage id='tabs_bar.search' defaultMessage='Search' /> |     <FormattedMessage id='tabs_bar.search' defaultMessage='Search' /> | ||||||
|   </NavLink>, |   </NavLink>, | ||||||
|  | |||||||
| @ -25,8 +25,11 @@ import TabsBar from './components/tabs_bar'; | |||||||
| import WhoToFollowPanel from './components/who_to_follow_panel'; | import WhoToFollowPanel from './components/who_to_follow_panel'; | ||||||
| import LinkFooter from './components/link_footer'; | import LinkFooter from './components/link_footer'; | ||||||
| import ProfilePage from 'gabsocial/pages/profile_page'; | 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 SearchPage from 'gabsocial/pages/search_page'; | ||||||
| import HomePage from 'gabsocial/pages/home_page'; | import HomePage from 'gabsocial/pages/home_page'; | ||||||
|  | import GroupSidebarPanel from '../groups/sidebar_panel'; | ||||||
| 
 | 
 | ||||||
| import { | import { | ||||||
|   Status, |   Status, | ||||||
| @ -55,6 +58,10 @@ import { | |||||||
|   GroupTimeline, |   GroupTimeline, | ||||||
|   ListTimeline, |   ListTimeline, | ||||||
|   Lists, |   Lists, | ||||||
|  |   GroupMembers, | ||||||
|  |   GroupRemovedAccounts, | ||||||
|  |   GroupCreate, | ||||||
|  |   GroupEdit, | ||||||
| } from './util/async-components'; | } from './util/async-components'; | ||||||
| import { me, meUsername } from '../../initial_state'; | import { me, meUsername } from '../../initial_state'; | ||||||
| import { previewState as previewMediaState } from './components/media_modal'; | 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.
 | // Dummy import, to make sure that <Status /> ends up in the application bundle.
 | ||||||
| // Without this it ends up in ~8 very commonly used bundles.
 | // Without this it ends up in ~8 very commonly used bundles.
 | ||||||
| import '../../components/status'; | import '../../components/status'; | ||||||
|  | import { fetchGroups } from '../../actions/groups'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Gab Social.' }, |   beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Gab Social.' }, | ||||||
| @ -116,12 +124,14 @@ const LAYOUT = { | |||||||
|     ], |     ], | ||||||
|     RIGHT: [ |     RIGHT: [ | ||||||
|       // <TrendsPanel />,
 |       // <TrendsPanel />,
 | ||||||
|  |       <GroupSidebarPanel /> | ||||||
|     ], |     ], | ||||||
|   }, |   }, | ||||||
|   STATUS: { |   STATUS: { | ||||||
|     TOP: null, |     TOP: null, | ||||||
|     LEFT: null, |     LEFT: null, | ||||||
|     RIGHT: [ |     RIGHT: [ | ||||||
|  |       <GroupSidebarPanel />, | ||||||
|       <WhoToFollowPanel key='0' />, |       <WhoToFollowPanel key='0' />, | ||||||
|       // <TrendsPanel />,
 |       // <TrendsPanel />,
 | ||||||
|       <LinkFooter key='1' />, |       <LinkFooter key='1' />, | ||||||
| @ -174,8 +184,14 @@ class SwitchingColumnsArea extends React.PureComponent { | |||||||
|         <WrappedRoute path='/home' exact page={HomePage} component={HomeTimeline} content={children} /> |         <WrappedRoute path='/home' exact page={HomePage} component={HomeTimeline} content={children} /> | ||||||
|         <WrappedRoute path='/timeline/all' exact page={HomePage} component={CommunityTimeline} content={children} /> |         <WrappedRoute path='/timeline/all' exact page={HomePage} component={CommunityTimeline} content={children} /> | ||||||
| 
 | 
 | ||||||
|         <WrappedRoute path='/groups' component={Groups} content={children} /> |         <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'featured' }} /> | ||||||
|         <WrappedRoute path='/groups/:id' component={GroupTimeline} content={children} /> |         <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} /> |         <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> | ||||||
| 
 | 
 | ||||||
| @ -360,6 +376,7 @@ class UI extends React.PureComponent { | |||||||
|     if (me) { |     if (me) { | ||||||
|       this.props.dispatch(expandHomeTimeline()); |       this.props.dispatch(expandHomeTimeline()); | ||||||
|       this.props.dispatch(expandNotifications()); |       this.props.dispatch(expandNotifications()); | ||||||
|  |       this.props.dispatch(fetchGroups('member')); | ||||||
| 
 | 
 | ||||||
|       setTimeout(() => this.props.dispatch(fetchFilters()), 500); |       setTimeout(() => this.props.dispatch(fetchFilters()), 500); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -34,6 +34,22 @@ export function GroupTimeline () { | |||||||
|   return import(/* webpackChunkName: "features/groups/timeline" */'../../groups/timeline'); |   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 () { | export function Groups () { | ||||||
|   return import(/* webpackChunkName: "features/groups/index" */'../../groups/index'); |   return import(/* webpackChunkName: "features/groups/index" */'../../groups/index'); | ||||||
| } | } | ||||||
|  | |||||||
| @ -54,6 +54,7 @@ | |||||||
|   "column.lists": "Lists", |   "column.lists": "Lists", | ||||||
|   "column.mutes": "Muted users", |   "column.mutes": "Muted users", | ||||||
|   "column.notifications": "Notifications", |   "column.notifications": "Notifications", | ||||||
|  |   "column.groups": "Groups", | ||||||
|   "column.pins": "Pinned gabs", |   "column.pins": "Pinned gabs", | ||||||
|   "column.public": "Federated timeline", |   "column.public": "Federated timeline", | ||||||
|   "column_back_button.label": "Back", |   "column_back_button.label": "Back", | ||||||
|  | |||||||
							
								
								
									
										73
									
								
								app/javascript/gabsocial/pages/group_page.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								app/javascript/gabsocial/pages/group_page.js
									
									
									
									
									
										Normal 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> | ||||||
|  | 		) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								app/javascript/gabsocial/pages/groups_page.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								app/javascript/gabsocial/pages/groups_page.js
									
									
									
									
									
										Normal 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> | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -9,6 +9,7 @@ import PromoPanel from '../features/ui/components/promo_panel'; | |||||||
| import UserPanel from '../features/ui/components/user_panel'; | import UserPanel from '../features/ui/components/user_panel'; | ||||||
| import ComposeFormContainer from '../features/compose/containers/compose_form_container'; | import ComposeFormContainer from '../features/compose/containers/compose_form_container'; | ||||||
| import Avatar from '../components/avatar'; | import Avatar from '../components/avatar'; | ||||||
|  | import GroupSidebarPanel from '../features/groups/sidebar_panel'; | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|   account: state.getIn(['accounts', me]), |   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 columns-area__panels__pane--right'> | ||||||
|               <div className='columns-area__panels__pane__inner'> |               <div className='columns-area__panels__pane__inner'> | ||||||
|                 <WhoToFollowPanel /> |                 <WhoToFollowPanel /> | ||||||
|  |                 <GroupSidebarPanel /> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|  | |||||||
							
								
								
									
										57
									
								
								app/javascript/gabsocial/reducers/group_editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								app/javascript/gabsocial/reducers/group_editor.js
									
									
									
									
									
										Normal 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; | ||||||
|  |     } | ||||||
|  | }; | ||||||
							
								
								
									
										21
									
								
								app/javascript/gabsocial/reducers/group_lists.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/javascript/gabsocial/reducers/group_lists.js
									
									
									
									
									
										Normal 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; | ||||||
|  |   } | ||||||
|  | }; | ||||||
| @ -3,6 +3,7 @@ import { | |||||||
|   GROUP_FETCH_FAIL, |   GROUP_FETCH_FAIL, | ||||||
|   GROUPS_FETCH_SUCCESS, |   GROUPS_FETCH_SUCCESS, | ||||||
| } from '../actions/groups'; | } from '../actions/groups'; | ||||||
|  | import { GROUP_UPDATE_SUCCESS } from '../actions/group_editor'; | ||||||
| import { Map as ImmutableMap, fromJS } from 'immutable'; | import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||||
| 
 | 
 | ||||||
| const initialState = ImmutableMap(); | const initialState = ImmutableMap(); | ||||||
| @ -20,6 +21,7 @@ const normalizeGroups = (state, groups) => { | |||||||
| export default function groups(state = initialState, action) { | export default function groups(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case GROUP_FETCH_SUCCESS: |   case GROUP_FETCH_SUCCESS: | ||||||
|  |   case GROUP_UPDATE_SUCCESS: | ||||||
|     return normalizeGroup(state, action.group); |     return normalizeGroup(state, action.group); | ||||||
|   case GROUPS_FETCH_SUCCESS: |   case GROUPS_FETCH_SUCCESS: | ||||||
|     return normalizeGroups(state, action.groups); |     return normalizeGroups(state, action.groups); | ||||||
|  | |||||||
| @ -34,6 +34,8 @@ import identity_proofs from './identity_proofs'; | |||||||
| import trends from './trends'; | import trends from './trends'; | ||||||
| import groups from './groups'; | import groups from './groups'; | ||||||
| import group_relationships from './group_relationships'; | import group_relationships from './group_relationships'; | ||||||
|  | import group_lists from './group_lists'; | ||||||
|  | import group_editor from './group_editor'; | ||||||
| 
 | 
 | ||||||
| const reducers = { | const reducers = { | ||||||
|   dropdown_menu, |   dropdown_menu, | ||||||
| @ -71,6 +73,8 @@ const reducers = { | |||||||
|   trends, |   trends, | ||||||
|   groups, |   groups, | ||||||
|   group_relationships, |   group_relationships, | ||||||
|  |   group_lists, | ||||||
|  |   group_editor, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default combineReducers(reducers); | export default combineReducers(reducers); | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ import { | |||||||
| } from '../actions/accounts'; | } from '../actions/accounts'; | ||||||
| import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; | import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; | ||||||
| import compareId from '../compare_id'; | import compareId from '../compare_id'; | ||||||
|  | import { GROUP_REMOVE_STATUS_SUCCESS } from '../actions/groups'; | ||||||
| 
 | 
 | ||||||
| const initialState = ImmutableMap(); | const initialState = ImmutableMap(); | ||||||
| 
 | 
 | ||||||
| @ -151,6 +152,10 @@ const filterTimeline = (timeline, state, relationship, statuses) => | |||||||
|       statuses.getIn([statusId, 'account']) === relationship.id |       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) { | export default function timelines(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|   case TIMELINE_EXPAND_REQUEST: |   case TIMELINE_EXPAND_REQUEST: | ||||||
| @ -187,6 +192,8 @@ export default function timelines(state = initialState, action) { | |||||||
|       initialTimeline, |       initialTimeline, | ||||||
|       map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) |       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: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -21,6 +21,13 @@ import { | |||||||
|   MUTES_EXPAND_SUCCESS, |   MUTES_EXPAND_SUCCESS, | ||||||
| } from '../actions/mutes'; | } from '../actions/mutes'; | ||||||
| import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | 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({ | const initialState = ImmutableMap({ | ||||||
|   followers: ImmutableMap(), |   followers: ImmutableMap(), | ||||||
| @ -30,6 +37,8 @@ const initialState = ImmutableMap({ | |||||||
|   follow_requests: ImmutableMap(), |   follow_requests: ImmutableMap(), | ||||||
|   blocks: ImmutableMap(), |   blocks: ImmutableMap(), | ||||||
|   mutes: ImmutableMap(), |   mutes: ImmutableMap(), | ||||||
|  |   groups: ImmutableMap(), | ||||||
|  |   groups_removed_accounts: ImmutableMap(), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const normalizeList = (state, type, id, accounts, next) => { | 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); |     return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); | ||||||
|   case MUTES_EXPAND_SUCCESS: |   case MUTES_EXPAND_SUCCESS: | ||||||
|     return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); |     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: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -18,12 +18,17 @@ | |||||||
| @import 'gabsocial/components'; | @import 'gabsocial/components'; | ||||||
| 
 | 
 | ||||||
| // COMPONENTS | // COMPONENTS | ||||||
|  | @import 'gabsocial/components/buttons'; | ||||||
| @import 'gabsocial/components/tabs-bar'; | @import 'gabsocial/components/tabs-bar'; | ||||||
| @import 'gabsocial/components/dropdown-menu'; | @import 'gabsocial/components/dropdown-menu'; | ||||||
| @import 'gabsocial/components/modal'; | @import 'gabsocial/components/modal'; | ||||||
| @import 'gabsocial/components/account-header'; | @import 'gabsocial/components/account-header'; | ||||||
| @import 'gabsocial/components/user-panel'; | @import 'gabsocial/components/user-panel'; | ||||||
| @import 'gabsocial/components/compose-form'; | @import 'gabsocial/components/compose-form'; | ||||||
|  | @import 'gabsocial/components/group-card'; | ||||||
|  | @import 'gabsocial/components/group-detail'; | ||||||
|  | @import 'gabsocial/components/group-form'; | ||||||
|  | @import 'gabsocial/components/group-sidebar-panel'; | ||||||
| 
 | 
 | ||||||
| @import 'gabsocial/polls'; | @import 'gabsocial/polls'; | ||||||
| @import 'gabsocial/introduction'; | @import 'gabsocial/introduction'; | ||||||
|  | |||||||
| @ -1,3 +1,23 @@ | |||||||
|  | // NEW GAB SPECIFIC MIXINS | ||||||
|  | 
 | ||||||
|  | // THEME MIXINS | ||||||
|  | 
 | ||||||
|  | // standard container drop shadow | ||||||
|  | @mixin light-theme-shadow() {box-shadow: 0 0 6px 0 rgba(0,0,0,0.1);} | ||||||
|  | 
 | ||||||
|  | // common properties for all standard containers | ||||||
|  | @mixin gab-container-standards() { | ||||||
|  | 	border-radius: 10px; | ||||||
|  | 	background: $gab-background-container; | ||||||
|  | 	body.theme-gabsocial-light & { | ||||||
|  | 		@include light-theme-shadow(); | ||||||
|  | 		background: $gab-background-container-light; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | // OLDER MIXINS | ||||||
| @mixin avatar-radius() { | @mixin avatar-radius() { | ||||||
|   border-radius: 50%; |   border-radius: 50%; | ||||||
|   background: transparent no-repeat; |   background: transparent no-repeat; | ||||||
|  | |||||||
| @ -1303,7 +1303,7 @@ a.account__display-name { | |||||||
| 	flex: 1 1 auto; | 	flex: 1 1 auto; | ||||||
| 	flex-direction: row; | 	flex-direction: row; | ||||||
| 	justify-content: flex-start; | 	justify-content: flex-start; | ||||||
| 	overflow-x: auto; | 	//overflow-x: auto; | ||||||
| 	position: relative; | 	position: relative; | ||||||
| 
 | 
 | ||||||
| 	&__panels { | 	&__panels { | ||||||
| @ -1321,14 +1321,14 @@ a.account__display-name { | |||||||
| 			pointer-events: none; | 			pointer-events: none; | ||||||
| 			display: flex; | 			display: flex; | ||||||
| 			justify-content: flex-end; | 			justify-content: flex-end; | ||||||
| 			padding-top: 10px; | 			padding-top: 15px; | ||||||
| 
 | 
 | ||||||
| 			&--start { | 			&--start { | ||||||
| 				justify-content: flex-start; | 				justify-content: flex-start; | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			&__inner { | 			&__inner { | ||||||
| 				width: 285px; | 				width: 265px; | ||||||
| 				pointer-events: auto; | 				pointer-events: auto; | ||||||
| 				height: 100%; | 				height: 100%; | ||||||
| 			} | 			} | ||||||
| @ -1342,7 +1342,8 @@ a.account__display-name { | |||||||
| 			flex-direction: column; | 			flex-direction: column; | ||||||
| 
 | 
 | ||||||
| 			@media screen and (min-width: 360px) { | 			@media screen and (min-width: 360px) { | ||||||
| 				padding: 0 10px; | 				margin: 0 20px; | ||||||
|  | 				padding: 0; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @ -1501,7 +1502,7 @@ a.account__display-name { | |||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	@media screen and (min-width: 360px) { | 	@media screen and (min-width: 360px) { | ||||||
| 		padding: 10px 0; | 		padding: 15px 0; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	@media screen and (min-width: 630px) { | 	@media screen and (min-width: 630px) { | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								app/javascript/styles/gabsocial/components/buttons.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/javascript/styles/gabsocial/components/buttons.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | button, | ||||||
|  | a.button { | ||||||
|  | 	&.standard { | ||||||
|  | 
 | ||||||
|  | 		// NOTE - will define the larger standard buttons here and apply class where used. | ||||||
|  | 
 | ||||||
|  | 		&-small { | ||||||
|  | 			height: 20px; | ||||||
|  | 			padding: 5px 15px; | ||||||
|  | 			border: none; | ||||||
|  | 			border-radius: 4px; | ||||||
|  | 			@include font-size(11); | ||||||
|  | 			@include line-height(11); | ||||||
|  | 			@include font-weight(bold); | ||||||
|  | 			text-transform: uppercase; | ||||||
|  | 			color: white; | ||||||
|  | 			background: $gab-small-cta-primary; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										72
									
								
								app/javascript/styles/gabsocial/components/group-card.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								app/javascript/styles/gabsocial/components/group-card.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,72 @@ | |||||||
|  | .group-column-header { | ||||||
|  |     overflow: hidden; | ||||||
|  |     @include gab-container-standards(); | ||||||
|  |     .group-column-header__title { | ||||||
|  |         padding: 15px; | ||||||
|  |         font-size: 20px; | ||||||
|  |         font-weight: bold; | ||||||
|  |     } | ||||||
|  |     .group-column-header__cta { | ||||||
|  |         float: right; | ||||||
|  |         padding: 15px; | ||||||
|  |         font-size: 17px; | ||||||
|  |         a {color: #fff;} | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .group-card-list { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: row; | ||||||
|  |     align-items: flex-start; | ||||||
|  |     flex-wrap: wrap; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     width: 100%; | ||||||
|  |     margin-top: 20px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .group-card { | ||||||
|  |     display: block; | ||||||
|  |     flex: 0 0 calc(50% - 20px/2); | ||||||
|  |     @include gab-container-standards(); | ||||||
|  |     margin-bottom: 15px; | ||||||
|  |     text-decoration: none; | ||||||
|  |     overflow: hidden; | ||||||
|  | 
 | ||||||
|  |     .group-card__header { | ||||||
|  |         overflow: hidden; | ||||||
|  |          | ||||||
|  |         img { | ||||||
|  |             pointer-events: none; | ||||||
|  |             width: 100%; | ||||||
|  |             background: $gab-background-container;; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group-card__content { | ||||||
|  |         padding: 15px; | ||||||
|  | 
 | ||||||
|  |         .group-card__title { | ||||||
|  |             color: $primary-text-color; | ||||||
|  |             font-size: 16px; | ||||||
|  |             font-weight: bold; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .group-card__meta { | ||||||
|  |             color: $gab-secondary-text; | ||||||
|  |             font-size: 14px; | ||||||
|  |             margin-top: 5px; | ||||||
|  |             margin-bottom: 10px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .group-card__description { | ||||||
|  |             color: $primary-text-color; | ||||||
|  |             font-size: 14px; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &:hover { | ||||||
|  |         .group-card__title { | ||||||
|  |             text-decoration: underline; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										84
									
								
								app/javascript/styles/gabsocial/components/group-detail.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								app/javascript/styles/gabsocial/components/group-detail.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | |||||||
|  | .group { | ||||||
|  |     .group__header-container { | ||||||
|  |         width: 100%; | ||||||
|  |         display: flex; | ||||||
|  |         justify-content: center; | ||||||
|  |         flex-direction: row; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group__header { | ||||||
|  |         width: 100%; | ||||||
|  |         max-width: 1150px; | ||||||
|  |         background: $gab-background-container; | ||||||
|  |         border-radius: 10px; | ||||||
|  |         overflow: hidden; | ||||||
|  |         margin: 20px 0; | ||||||
|  | 
 | ||||||
|  |         .group__cover { | ||||||
|  |             img { | ||||||
|  |                 width: 100%; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .group__tabs { | ||||||
|  |             .group__tabs__tab { | ||||||
|  |                 display: inline-block; | ||||||
|  | 				text-decoration: none; | ||||||
|  | 				padding: 16px 22px; | ||||||
|  |                 text-align: center; | ||||||
|  |                 color: $primary-text-color; | ||||||
|  |                  | ||||||
|  |                 &:hover, | ||||||
|  |                 &--active { | ||||||
|  |                     border-bottom: 2px solid $primary-text-color; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             &:after { | ||||||
|  |                 content: ""; | ||||||
|  |                 clear: both; | ||||||
|  |                 display: table; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             button { | ||||||
|  |                 float: right; | ||||||
|  |                 margin: 7px; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             div { | ||||||
|  |                 float: right; | ||||||
|  |                 margin: 4px; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group__panel { | ||||||
|  |         padding: 10px 10px 20px 10px; | ||||||
|  | 
 | ||||||
|  |         h1 { | ||||||
|  |             font-size: 22px; | ||||||
|  |             font-weight: bold; | ||||||
|  |             margin-bottom: 10px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .group__panel__description { | ||||||
|  |             font-size: 14px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .group__panel__label { | ||||||
|  |             display: inline-block; | ||||||
|  |             margin-bottom: 10px; | ||||||
|  |             border-radius: 4px; | ||||||
|  |             background: $gab-background-container; | ||||||
|  |             font-size: 13px; | ||||||
|  |             padding: 4px 8px; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group__feed { | ||||||
|  |       background: $gab-background-container; | ||||||
|  |       border-top-left-radius: 10px; | ||||||
|  |       border-top-right-radius: 10px; | ||||||
|  |       overflow: hidden; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										53
									
								
								app/javascript/styles/gabsocial/components/group-form.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/javascript/styles/gabsocial/components/group-form.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | |||||||
|  | .group-form { | ||||||
|  |     background: $gab-background-container; | ||||||
|  |     padding: 15px; | ||||||
|  |     border-radius: 10px; | ||||||
|  | 
 | ||||||
|  |     .group-form__input { | ||||||
|  |         height: 40px; | ||||||
|  |         padding: 0 15px; | ||||||
|  |         display: block; | ||||||
|  |         color: $primary-text-color; | ||||||
|  |         background: none; | ||||||
|  |         border: 1px solid $secondary-text-color; | ||||||
|  |         border-radius: 4px; | ||||||
|  |         margin-bottom: 10px; | ||||||
|  |         width: 100%; | ||||||
|  |         box-sizing: border-box; | ||||||
|  | 
 | ||||||
|  |         &:focus { | ||||||
|  |             outline: none; | ||||||
|  |             border-color:  $primary-text-color; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group-form__file-label { | ||||||
|  |         font-size: 13px; | ||||||
|  |         color: $secondary-text-color; | ||||||
|  |         cursor: pointer; | ||||||
|  | 
 | ||||||
|  |         &:hover { | ||||||
|  |             color: $primary-text-color; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .fa { | ||||||
|  |             font-size: 18px; | ||||||
|  |             margin-right: 5px; | ||||||
|  |             transform: translatey(2px); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         &.group-form__file-label--selected { | ||||||
|  |             color: $primary-text-color; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group-form__file { | ||||||
|  |         width: 1px; | ||||||
|  |         height: 1px; | ||||||
|  |         overflow: hidden; | ||||||
|  |         opacity: 0; | ||||||
|  |         position: absolute; | ||||||
|  |         pointer-events: none; | ||||||
|  |     } | ||||||
|  |     button {float: right;} | ||||||
|  | } | ||||||
| @ -0,0 +1,33 @@ | |||||||
|  | .group-sidebar-panel { | ||||||
|  |     &__items { | ||||||
|  |         padding: 0 15px 15px; | ||||||
|  | 
 | ||||||
|  |         &__show-all { | ||||||
|  |             color: $primary-text-color; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &__item { | ||||||
|  |         display: block; | ||||||
|  |         color: $primary-text-color; | ||||||
|  |         text-decoration: none; | ||||||
|  |         margin-bottom: 15px; | ||||||
|  | 
 | ||||||
|  |         &:last-child { | ||||||
|  |             margin-bottom: 0; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         &__title { | ||||||
|  |             font-weight: bold; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         &__meta { | ||||||
|  |             font-size: 0.8em; | ||||||
|  |             color: $gab-secondary-text; | ||||||
|  | 
 | ||||||
|  |             &__unread { | ||||||
|  |                 color: $gab-brand-default; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -1,15 +1,9 @@ | |||||||
| .user-panel { | .user-panel { | ||||||
| 	display: flex; | 	display: flex; | ||||||
| 	width: 285px; | 	width: 265px; | ||||||
| 	flex-direction: column; | 	flex-direction: column; | ||||||
| 	overflow-y: hidden; | 	overflow-y: hidden; | ||||||
| 	border-radius: 10px; | 	@include gab-container-standards(); | ||||||
| 	background: $gab-background-container; |  | ||||||
| 	body.theme-gabsocial-light & { |  | ||||||
| 		@include light-theme-shadow(); |  | ||||||
| 		background: $gab-background-container-light; |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	&__header { | 	&__header { | ||||||
| 		display: block; | 		display: block; | ||||||
| 		height: 112px; | 		height: 112px; | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ $gab-brand-default: #21cf7a; | |||||||
| $gab-alert-red: #cc6643; | $gab-alert-red: #cc6643; | ||||||
| $gab-secondary-text: #999; | $gab-secondary-text: #999; | ||||||
| $gab-text-highlight: $gab-brand-default; | $gab-text-highlight: $gab-brand-default; | ||||||
|  | $gab-small-cta-primary: #607CF5; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| // THEME COLORS | // THEME COLORS | ||||||
| @ -28,8 +29,7 @@ $gab-background-container-light: #fff; | |||||||
| $gab-default-text-light: #6c6c6c; | $gab-default-text-light: #6c6c6c; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| // theme mixins | 
 | ||||||
| @mixin light-theme-shadow() {box-shadow: 0 0 6px 0 rgba(0,0,0,0.1);} |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| // BREAKPOINT SETS | // BREAKPOINT SETS | ||||||
|  | |||||||
| @ -34,6 +34,7 @@ class UserSettingsDecorator | |||||||
|     user.settings['aggregate_reblogs']   = aggregate_reblogs_preference if change?('setting_aggregate_reblogs') |     user.settings['aggregate_reblogs']   = aggregate_reblogs_preference if change?('setting_aggregate_reblogs') | ||||||
|     user.settings['show_application']    = show_application_preference if change?('setting_show_application') |     user.settings['show_application']    = show_application_preference if change?('setting_show_application') | ||||||
|     user.settings['advanced_layout']     = advanced_layout_preference if change?('setting_advanced_layout') |     user.settings['advanced_layout']     = advanced_layout_preference if change?('setting_advanced_layout') | ||||||
|  |     user.settings['group_in_home_feed']  = group_in_home_feed_preference if change?('setting_group_in_home_feed') | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def merged_notification_emails |   def merged_notification_emails | ||||||
| @ -112,6 +113,10 @@ class UserSettingsDecorator | |||||||
|     boolean_cast_setting 'setting_advanced_layout' |     boolean_cast_setting 'setting_advanced_layout' | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def group_in_home_feed_preference | ||||||
|  |     boolean_cast_setting 'setting_group_in_home_feed' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def boolean_cast_setting(key) |   def boolean_cast_setting(key) | ||||||
|     ActiveModel::Type::Boolean.new.cast(settings[key]) |     ActiveModel::Type::Boolean.new.cast(settings[key]) | ||||||
|   end |   end | ||||||
|  | |||||||
							
								
								
									
										33
									
								
								app/models/concerns/group_cover_image.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/models/concerns/group_cover_image.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | module GroupCoverImage | ||||||
|  |   extend ActiveSupport::Concern | ||||||
|  |    | ||||||
|  |   LIMIT            = 4.megabytes | ||||||
|  |   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze | ||||||
|  | 
 | ||||||
|  |   class_methods do | ||||||
|  |     def cover_image_styles(file) | ||||||
|  |       styles = { original: { geometry: '1200x475#', file_geometry_parser: FastGeometryParser } } | ||||||
|  |       styles[:static] = { geometry: '1200x475#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif' | ||||||
|  |       styles | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     private :cover_image_styles | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   included do | ||||||
|  |     has_attached_file :cover_image, styles: ->(f) { cover_image_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail] | ||||||
|  |     validates_attachment_content_type :cover_image, content_type: IMAGE_MIME_TYPES | ||||||
|  |     validates_attachment_size :cover_image, less_than: LIMIT | ||||||
|  |     remotable_attachment :cover_image, LIMIT | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def cover_image_original_url | ||||||
|  |     cover_image.url(:original) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def cover_image_static_url | ||||||
|  |     cover_image_content_type == 'image/gif' ? cover_image.url(:static) : cover_image_original_url | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -13,11 +13,19 @@ module GroupInteractions | |||||||
|       follow_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id, role: :admin), :group_id) |       follow_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id, role: :admin), :group_id) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     def unread_count_map(target_group_ids, account_id) | ||||||
|  |       unread_count_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id), :unread_count) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     private |     private | ||||||
| 
 | 
 | ||||||
|     def follow_mapping(query, field) |     def follow_mapping(query, field) | ||||||
|       query.pluck(field).each_with_object({}) { |id, mapping| mapping[id] = true } |       query.pluck(field).each_with_object({}) { |id, mapping| mapping[id] = true } | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     def unread_count_mapping(query, field) | ||||||
|  |       query.pluck(:group_id, :unread_count).each_with_object({}) { |e, mapping| mapping[e[0]] = e[1] } | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
| end | end | ||||||
|  | |||||||
| @ -15,11 +15,13 @@ | |||||||
| #  is_archived              :boolean          default(FALSE), not null | #  is_archived              :boolean          default(FALSE), not null | ||||||
| #  created_at               :datetime         not null | #  created_at               :datetime         not null | ||||||
| #  updated_at               :datetime         not null | #  updated_at               :datetime         not null | ||||||
|  | #  member_count             :integer          default(0) | ||||||
| # | # | ||||||
| 
 | 
 | ||||||
| class Group < ApplicationRecord | class Group < ApplicationRecord | ||||||
|   include Paginable |   include Paginable | ||||||
|   include GroupInteractions |   include GroupInteractions | ||||||
|  |   include GroupCoverImage | ||||||
| 
 | 
 | ||||||
|   PER_ACCOUNT_LIMIT = 50 |   PER_ACCOUNT_LIMIT = 50 | ||||||
| 
 | 
 | ||||||
| @ -28,17 +30,12 @@ class Group < ApplicationRecord | |||||||
|   has_many :group_accounts, inverse_of: :group, dependent: :destroy |   has_many :group_accounts, inverse_of: :group, dependent: :destroy | ||||||
|   has_many :accounts, through: :group_accounts |   has_many :accounts, through: :group_accounts | ||||||
| 
 | 
 | ||||||
|  |   has_many :group_removed_accounts, inverse_of: :group, dependent: :destroy | ||||||
|  |   has_many :removed_accounts, source: :account, through: :group_removed_accounts | ||||||
|  | 
 | ||||||
|   validates :title, presence: true |   validates :title, presence: true | ||||||
|   validates :description, presence: true |   validates :description, presence: true | ||||||
| 
 | 
 | ||||||
|   LIMIT            = 4.megabytes |  | ||||||
|   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze |  | ||||||
| 
 |  | ||||||
|   has_attached_file :cover_image |  | ||||||
|   validates_attachment_content_type :cover_image, content_type: IMAGE_MIME_TYPES |  | ||||||
|   validates_attachment_size :cover_image, less_than: LIMIT |  | ||||||
|   remotable_attachment :cover_image, LIMIT |  | ||||||
| 
 |  | ||||||
|   validates_each :account_id, on: :create do |record, _attr, value| |   validates_each :account_id, on: :create do |record, _attr, value| | ||||||
|     record.errors.add(:base, I18n.t('groups.errors.limit')) if Group.where(account_id: value).count >= PER_ACCOUNT_LIMIT |     record.errors.add(:base, I18n.t('groups.errors.limit')) if Group.where(account_id: value).count >= PER_ACCOUNT_LIMIT | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ | |||||||
| #  role              :string | #  role              :string | ||||||
| #  created_at        :datetime         not null | #  created_at        :datetime         not null | ||||||
| #  updated_at        :datetime         not null | #  updated_at        :datetime         not null | ||||||
|  | #  unread_count      :integer          default(0) | ||||||
| # | # | ||||||
| 
 | 
 | ||||||
| class GroupAccount < ApplicationRecord | class GroupAccount < ApplicationRecord | ||||||
| @ -18,4 +19,22 @@ class GroupAccount < ApplicationRecord | |||||||
|   belongs_to :account |   belongs_to :account | ||||||
| 
 | 
 | ||||||
|   validates :account_id, uniqueness: { scope: :group_id } |   validates :account_id, uniqueness: { scope: :group_id } | ||||||
|  | 
 | ||||||
|  |   after_commit :remove_relationship_cache | ||||||
|  |   after_create :increment_member_count | ||||||
|  |   after_destroy :decrement_member_count | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def remove_relationship_cache | ||||||
|  |     Rails.cache.delete("relationship:#{account_id}:group#{group_id}") | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def increment_member_count | ||||||
|  |     group&.increment!(:member_count) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def decrement_member_count | ||||||
|  |     group&.decrement!(:member_count) | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								app/models/group_removed_account.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/models/group_removed_account.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | |||||||
|  | # == Schema Information | ||||||
|  | # | ||||||
|  | # Table name: group_removed_accounts | ||||||
|  | # | ||||||
|  | #  id         :bigint(8)        not null, primary key | ||||||
|  | #  group_id   :bigint(8)        not null | ||||||
|  | #  account_id :bigint(8)        not null | ||||||
|  | #  created_at :datetime         not null | ||||||
|  | #  updated_at :datetime         not null | ||||||
|  | # | ||||||
|  | 
 | ||||||
|  | class GroupRemovedAccount < ApplicationRecord | ||||||
|  |   belongs_to :group | ||||||
|  |   belongs_to :account | ||||||
|  | end | ||||||
| @ -256,6 +256,7 @@ class Status < ApplicationRecord | |||||||
| 
 | 
 | ||||||
|   after_create_commit :store_uri, if: :local? |   after_create_commit :store_uri, if: :local? | ||||||
|   after_create_commit :update_statistics, if: :local? |   after_create_commit :update_statistics, if: :local? | ||||||
|  |   after_create_commit :increase_group_unread_counts, if: Proc.new { |status| !status.group_id.nil? } | ||||||
| 
 | 
 | ||||||
|   around_create GabSocial::Snowflake::Callbacks |   around_create GabSocial::Snowflake::Callbacks | ||||||
| 
 | 
 | ||||||
| @ -535,4 +536,8 @@ class Status < ApplicationRecord | |||||||
|       AccountConversation.remove_status(inbox_owner, self) |       AccountConversation.remove_status(inbox_owner, self) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def increase_group_unread_counts | ||||||
|  |     GroupAccount.where(group_id: group_id).where.not(account_id: account_id).update_all("unread_count = unread_count + 1") | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -105,7 +105,7 @@ class User < ApplicationRecord | |||||||
|   delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, |   delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, | ||||||
|            :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, |            :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, | ||||||
|            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, |            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, | ||||||
|            :advanced_layout, to: :settings, prefix: :setting, allow_nil: false |            :advanced_layout, :group_in_home_feed, to: :settings, prefix: :setting, allow_nil: false | ||||||
| 
 | 
 | ||||||
|   attr_reader :invite_code |   attr_reader :invite_code | ||||||
|   attr_writer :external |   attr_writer :external | ||||||
|  | |||||||
| @ -23,7 +23,8 @@ class GroupPolicy < ApplicationPolicy | |||||||
| 
 | 
 | ||||||
|   def join? |   def join? | ||||||
|     check_archive! |     check_archive! | ||||||
|     raise GabSocial::ValidationError, "User is already a member of this group." if is_member? |     raise GabSocial::ValidationError, "Account is already a member of this group." if is_member? | ||||||
|  |     raise GabSocial::ValidationError, "Account is removed from this group." if is_removed? | ||||||
| 
 | 
 | ||||||
|     return true |     return true | ||||||
|   end |   end | ||||||
| @ -42,8 +43,24 @@ class GroupPolicy < ApplicationPolicy | |||||||
|     is_group_admin? |     is_group_admin? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def show_removed_accounts? | ||||||
|  |     is_group_admin? | ||||||
|  |   end | ||||||
|  |    | ||||||
|  |   def create_removed_account? | ||||||
|  |     is_group_admin? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def destroy_removed_account? | ||||||
|  |     is_group_admin? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|  |   def is_removed? | ||||||
|  |     record.group_removed_accounts.where(account_id: current_account.id).exists? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def is_member? |   def is_member? | ||||||
|     record.group_accounts.where(account_id: current_account.id).exists? |     record.group_accounts.where(account_id: current_account.id).exists? | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -1,55 +1,15 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class GroupRelationshipsPresenter | class GroupRelationshipsPresenter | ||||||
|     attr_reader :member, :admin |     attr_reader :member, :admin, :unread_count | ||||||
|    |    | ||||||
|     def initialize(group_ids, current_account_id, **options) |     def initialize(group_ids, current_account_id, **options) | ||||||
|       @group_ids          = group_ids.map { |a| a.is_a?(Group) ? a.id : a } |       @group_ids          = group_ids.map { |a| a.is_a?(Group) ? a.id : a } | ||||||
|       @current_account_id = current_account_id |       @current_account_id = current_account_id | ||||||
|    |    | ||||||
|       @member    = cached[:member].merge(Group.member_map(@uncached_group_ids, @current_account_id)) |       @member       = Group.member_map(@group_ids, @current_account_id) | ||||||
|       @admin     = cached[:admin].merge(Group.admin_map(@uncached_group_ids, @current_account_id)) |       @admin        = Group.admin_map(@group_ids, @current_account_id) | ||||||
|    |       @unread_count = Group.unread_count_map(@group_ids, @current_account_id) | ||||||
|       cache_uncached! |  | ||||||
|    |  | ||||||
|       @member.merge!(options[:member_map] || {}) |  | ||||||
|       @admin.merge!(options[:admin_map] || {}) |  | ||||||
|     end |  | ||||||
|    |  | ||||||
|     private |  | ||||||
|    |  | ||||||
|     def cached |  | ||||||
|       return @cached if defined?(@cached) |  | ||||||
|    |  | ||||||
|       @cached = { |  | ||||||
|         member: {}, |  | ||||||
|         admin: {}, |  | ||||||
|       } |  | ||||||
|    |  | ||||||
|       @uncached_group_ids = [] |  | ||||||
|        |  | ||||||
|       @group_ids.each do |group_id| |  | ||||||
|         maps_for_group = Rails.cache.read("relationship:#{@current_account_id}:#{group_id}") |  | ||||||
|    |  | ||||||
|         if maps_for_group.is_a?(Hash) |  | ||||||
|           @cached.deep_merge!(maps_for_group) |  | ||||||
|         else |  | ||||||
|           @uncached_group_ids << group_id |  | ||||||
|         end |  | ||||||
|       end |  | ||||||
|    |  | ||||||
|       @cached |  | ||||||
|     end |  | ||||||
|    |  | ||||||
|     def cache_uncached! |  | ||||||
|       @uncached_group_ids.each do |group_id| |  | ||||||
|         maps_for_account = { |  | ||||||
|           member:     { group_id => member[group_id] }, |  | ||||||
|           admin:      { group_id => admin[group_id] }, |  | ||||||
|         } |  | ||||||
|    |  | ||||||
|         Rails.cache.write("relationship:#{@current_account_id}:#{group_id}", maps_for_account, expires_in: 1.day) |  | ||||||
|       end |  | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|    |    | ||||||
| @ -33,6 +33,7 @@ class InitialStateSerializer < ActiveModel::Serializer | |||||||
|       store[:expand_spoilers]    = object.current_account.user.setting_expand_spoilers |       store[:expand_spoilers]    = object.current_account.user.setting_expand_spoilers | ||||||
|       store[:reduce_motion]      = object.current_account.user.setting_reduce_motion |       store[:reduce_motion]      = object.current_account.user.setting_reduce_motion | ||||||
|       store[:advanced_layout]    = object.current_account.user.setting_advanced_layout |       store[:advanced_layout]    = object.current_account.user.setting_advanced_layout | ||||||
|  |       store[:group_in_home_feed] = object.current_account.user.setting_group_in_home_feed | ||||||
|       store[:is_staff]           = object.current_account.user.staff? |       store[:is_staff]           = object.current_account.user.staff? | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class REST::GroupRelationshipSerializer < ActiveModel::Serializer | class REST::GroupRelationshipSerializer < ActiveModel::Serializer | ||||||
|   attributes :id, :member, :admin |   attributes :id, :member, :admin, :unread_count | ||||||
| 
 | 
 | ||||||
|   def id |   def id | ||||||
|     object.id.to_s |     object.id.to_s | ||||||
| @ -14,4 +14,8 @@ class REST::GroupRelationshipSerializer < ActiveModel::Serializer | |||||||
|   def admin |   def admin | ||||||
|     instance_options[:relationships].admin[object.id] ? true : false |     instance_options[:relationships].admin[object.id] ? true : false | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def unread_count | ||||||
|  |     instance_options[:relationships].unread_count[object.id] || 0 | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -3,13 +3,23 @@ | |||||||
| class REST::GroupSerializer < ActiveModel::Serializer | class REST::GroupSerializer < ActiveModel::Serializer | ||||||
|   include RoutingHelper |   include RoutingHelper | ||||||
|    |    | ||||||
|   attributes :id, :title, :description, :cover_image_url, :is_archived |   attributes :id, :title, :description, :cover_image_url, :is_archived, :member_count | ||||||
| 
 | 
 | ||||||
|   def id |   def id | ||||||
|     object.id.to_s |     object.id.to_s | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def clean_migrated_url | ||||||
|  |     object | ||||||
|  |       .cover_image_file_name | ||||||
|  |       .sub("gab://groups/", "https://gab.com/media/user/") | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def cover_image_url |   def cover_image_url | ||||||
|  |     if object.cover_image_file_name and object.cover_image_file_name.start_with? "gab://groups/" | ||||||
|  |       return clean_migrated_url | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|     full_asset_url(object.cover_image.url) |     full_asset_url(object.cover_image.url) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer | |||||||
|   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, |   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, | ||||||
|              :sensitive, :spoiler_text, :visibility, :language, |              :sensitive, :spoiler_text, :visibility, :language, | ||||||
|              :uri, :url, :replies_count, :reblogs_count, |              :uri, :url, :replies_count, :reblogs_count, | ||||||
|              :favourites_count |              :favourites_count, :group_id | ||||||
| 
 | 
 | ||||||
|   attribute :favourited, if: :current_user? |   attribute :favourited, if: :current_user? | ||||||
|   attribute :reblogged, if: :current_user? |   attribute :reblogged, if: :current_user? | ||||||
|  | |||||||
| @ -2,7 +2,6 @@ | |||||||
| 
 | 
 | ||||||
| class GroupQueryService < BaseService | class GroupQueryService < BaseService | ||||||
|   def call(group) |   def call(group) | ||||||
|     Status.distinct |     Status.as_group_timeline(group) | ||||||
|           .as_group_timeline(group) |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -62,6 +62,7 @@ | |||||||
|     = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label |     = f.input :setting_expand_spoilers, as: :boolean, wrapper: :with_label | ||||||
|     = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label |     = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label | ||||||
|     = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label |     = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label | ||||||
|  |     = f.input :setting_group_in_home_feed, as: :boolean, wrapper: :with_label | ||||||
| 
 | 
 | ||||||
|   .actions |   .actions | ||||||
|     = f.button :button, t('generic.save_changes'), type: :submit |     = f.button :button, t('generic.save_changes'), type: :submit | ||||||
|  | |||||||
| @ -105,6 +105,7 @@ en: | |||||||
|         setting_hide_network: Hide your network |         setting_hide_network: Hide your network | ||||||
|         setting_noindex: Opt-out of search engine indexing |         setting_noindex: Opt-out of search engine indexing | ||||||
|         setting_reduce_motion: Reduce motion in animations |         setting_reduce_motion: Reduce motion in animations | ||||||
|  |         setting_group_in_home_feed: Show posts from your groups | ||||||
|         setting_show_application: Disclose application used to send gabs |         setting_show_application: Disclose application used to send gabs | ||||||
|         setting_system_font_ui: Use system's default font |         setting_system_font_ui: Use system's default font | ||||||
|         setting_theme: Site theme |         setting_theme: Site theme | ||||||
|  | |||||||
| @ -404,6 +404,7 @@ Rails.application.routes.draw do | |||||||
| 
 | 
 | ||||||
|         resources :relationships, only: :index, controller: 'groups/relationships' |         resources :relationships, only: :index, controller: 'groups/relationships' | ||||||
|         resource :accounts, only: [:show, :create, :update, :destroy], controller: 'groups/accounts' |         resource :accounts, only: [:show, :create, :update, :destroy], controller: 'groups/accounts' | ||||||
|  |         resource :removed_accounts, only: [:show, :create, :destroy], controller: 'groups/removed_accounts' | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       resources :polls, only: [:create, :show] do |       resources :polls, only: [:create, :show] do | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								db/migrate/20190716173227_create_group_removed_accounts.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								db/migrate/20190716173227_create_group_removed_accounts.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | class CreateGroupRemovedAccounts < ActiveRecord::Migration[5.2] | ||||||
|  |   def change | ||||||
|  |     create_table :group_removed_accounts do |t| | ||||||
|  |       t.belongs_to :group, foreign_key: { on_delete: :cascade }, null: false | ||||||
|  |       t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false | ||||||
|  |       t.timestamps | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     add_index :group_removed_accounts, [:account_id, :group_id], unique: true | ||||||
|  |     add_index :group_removed_accounts, [:group_id, :account_id] | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -0,0 +1,10 @@ | |||||||
|  | class AddUnreadCountToGroupAccounts < ActiveRecord::Migration[5.2] | ||||||
|  |   def up | ||||||
|  |     add_column :group_accounts, :unread_count, :integer | ||||||
|  |     change_column_default :group_accounts, :unread_count, 0 | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_column :group_accounts, :unread_count | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -0,0 +1,10 @@ | |||||||
|  | class BackfillAddUnreadCountToGroupAccounts < ActiveRecord::Migration[5.2] | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   def change | ||||||
|  |     GroupAccount.in_batches do |relation| | ||||||
|  |       relation.update_all unread_count: 0 | ||||||
|  |       sleep(0.1) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										10
									
								
								db/migrate/20190722003541_add_member_count_to_groups.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								db/migrate/20190722003541_add_member_count_to_groups.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | class AddMemberCountToGroups < ActiveRecord::Migration[5.2] | ||||||
|  |   def up | ||||||
|  |     add_column :groups, :member_count, :integer | ||||||
|  |     change_column_default :groups, :member_count, 0 | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     remove_column :groups, :member_count | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -0,0 +1,10 @@ | |||||||
|  | class BackfillAddMemberCountToGroups < ActiveRecord::Migration[5.2] | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   def change | ||||||
|  |     Group.in_batches do |relation| | ||||||
|  |       relation.update_all member_count: 0 | ||||||
|  |       sleep(0.1) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										27
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								db/schema.rb
									
									
									
									
									
								
							| @ -10,7 +10,7 @@ | |||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||||
| 
 | 
 | ||||||
| ActiveRecord::Schema.define(version: 2019_06_07_000211) do | ActiveRecord::Schema.define(version: 2019_07_22_003649) do | ||||||
| 
 | 
 | ||||||
|   # These are extensions that must be enabled in order to support this database |   # These are extensions that must be enabled in order to support this database | ||||||
|   enable_extension "plpgsql" |   enable_extension "plpgsql" | ||||||
| @ -93,7 +93,7 @@ ActiveRecord::Schema.define(version: 2019_06_07_000211) do | |||||||
|     t.bigint "account_id" |     t.bigint "account_id" | ||||||
|     t.string "image_file_name" |     t.string "image_file_name" | ||||||
|     t.string "image_content_type" |     t.string "image_content_type" | ||||||
|     t.bigint "image_file_size" |     t.integer "image_file_size" | ||||||
|     t.datetime "image_updated_at" |     t.datetime "image_updated_at" | ||||||
|     t.datetime "created_at", null: false |     t.datetime "created_at", null: false | ||||||
|     t.datetime "updated_at", null: false |     t.datetime "updated_at", null: false | ||||||
| @ -157,10 +157,10 @@ ActiveRecord::Schema.define(version: 2019_06_07_000211) do | |||||||
|     t.string "actor_type" |     t.string "actor_type" | ||||||
|     t.boolean "discoverable" |     t.boolean "discoverable" | ||||||
|     t.string "also_known_as", array: true |     t.string "also_known_as", array: true | ||||||
|     t.datetime "silenced_at" |  | ||||||
|     t.datetime "suspended_at" |  | ||||||
|     t.boolean "is_pro", default: false, null: false |     t.boolean "is_pro", default: false, null: false | ||||||
|     t.datetime "pro_expires_at" |     t.datetime "pro_expires_at" | ||||||
|  |     t.datetime "silenced_at" | ||||||
|  |     t.datetime "suspended_at" | ||||||
|     t.boolean "is_verified", default: false, null: false |     t.boolean "is_verified", default: false, null: false | ||||||
|     t.boolean "is_donor", default: false, null: false |     t.boolean "is_donor", default: false, null: false | ||||||
|     t.boolean "is_investor", default: false, null: false |     t.boolean "is_investor", default: false, null: false | ||||||
| @ -327,12 +327,24 @@ ActiveRecord::Schema.define(version: 2019_06_07_000211) do | |||||||
|     t.string "role" |     t.string "role" | ||||||
|     t.datetime "created_at", null: false |     t.datetime "created_at", null: false | ||||||
|     t.datetime "updated_at", null: false |     t.datetime "updated_at", null: false | ||||||
|  |     t.integer "unread_count", default: 0 | ||||||
|     t.index ["account_id", "group_id"], name: "index_group_accounts_on_account_id_and_group_id", unique: true |     t.index ["account_id", "group_id"], name: "index_group_accounts_on_account_id_and_group_id", unique: true | ||||||
|     t.index ["account_id"], name: "index_group_accounts_on_account_id" |     t.index ["account_id"], name: "index_group_accounts_on_account_id" | ||||||
|     t.index ["group_id", "account_id"], name: "index_group_accounts_on_group_id_and_account_id" |     t.index ["group_id", "account_id"], name: "index_group_accounts_on_group_id_and_account_id" | ||||||
|     t.index ["group_id"], name: "index_group_accounts_on_group_id" |     t.index ["group_id"], name: "index_group_accounts_on_group_id" | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   create_table "group_removed_accounts", force: :cascade do |t| | ||||||
|  |     t.bigint "group_id", null: false | ||||||
|  |     t.bigint "account_id", null: false | ||||||
|  |     t.datetime "created_at", null: false | ||||||
|  |     t.datetime "updated_at", null: false | ||||||
|  |     t.index ["account_id", "group_id"], name: "index_group_removed_accounts_on_account_id_and_group_id", unique: true | ||||||
|  |     t.index ["account_id"], name: "index_group_removed_accounts_on_account_id" | ||||||
|  |     t.index ["group_id", "account_id"], name: "index_group_removed_accounts_on_group_id_and_account_id" | ||||||
|  |     t.index ["group_id"], name: "index_group_removed_accounts_on_group_id" | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   create_table "groups", force: :cascade do |t| |   create_table "groups", force: :cascade do |t| | ||||||
|     t.bigint "account_id" |     t.bigint "account_id" | ||||||
|     t.string "title", null: false |     t.string "title", null: false | ||||||
| @ -346,6 +358,7 @@ ActiveRecord::Schema.define(version: 2019_06_07_000211) do | |||||||
|     t.boolean "is_archived", default: false, null: false |     t.boolean "is_archived", default: false, null: false | ||||||
|     t.datetime "created_at", null: false |     t.datetime "created_at", null: false | ||||||
|     t.datetime "updated_at", null: false |     t.datetime "updated_at", null: false | ||||||
|  |     t.integer "member_count", default: 0 | ||||||
|     t.index ["account_id"], name: "index_groups_on_account_id" |     t.index ["account_id"], name: "index_groups_on_account_id" | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
| @ -645,8 +658,8 @@ ActiveRecord::Schema.define(version: 2019_06_07_000211) do | |||||||
|   create_table "status_pins", force: :cascade do |t| |   create_table "status_pins", force: :cascade do |t| | ||||||
|     t.bigint "account_id", null: false |     t.bigint "account_id", null: false | ||||||
|     t.bigint "status_id", null: false |     t.bigint "status_id", null: false | ||||||
|     t.datetime "created_at", default: -> { "now()" }, null: false |     t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false | ||||||
|     t.datetime "updated_at", default: -> { "now()" }, null: false |     t.datetime "updated_at", default: -> { "CURRENT_TIMESTAMP" }, null: false | ||||||
|     t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true |     t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
| @ -849,6 +862,8 @@ ActiveRecord::Schema.define(version: 2019_06_07_000211) do | |||||||
|   add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade |   add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade | ||||||
|   add_foreign_key "group_accounts", "accounts", on_delete: :cascade |   add_foreign_key "group_accounts", "accounts", on_delete: :cascade | ||||||
|   add_foreign_key "group_accounts", "groups", on_delete: :cascade |   add_foreign_key "group_accounts", "groups", on_delete: :cascade | ||||||
|  |   add_foreign_key "group_removed_accounts", "accounts", on_delete: :cascade | ||||||
|  |   add_foreign_key "group_removed_accounts", "groups", on_delete: :cascade | ||||||
|   add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade |   add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade | ||||||
|   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade |   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade | ||||||
|   add_foreign_key "invites", "users", on_delete: :cascade |   add_foreign_key "invites", "users", on_delete: :cascade | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								lib/tasks/fix_group_member_counts.rake
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								lib/tasks/fix_group_member_counts.rake
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | task fix_group_member_counts: 'gabsocial:fix-group-member-counts' | ||||||
|  | 
 | ||||||
|  | namespace :gabsocial do | ||||||
|  |   desc 'Re-compute group member counts' | ||||||
|  |   task :fix_group_member_counts => :environment do | ||||||
|  |     Group.select(:id).all.each do |group| | ||||||
|  |       group.update_column(:member_count, group.accounts.count) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										2
									
								
								spec/fabricators/group_removed_account_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								spec/fabricators/group_removed_account_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | |||||||
|  | Fabricator(:group_removed_account) do | ||||||
|  | end | ||||||
							
								
								
									
										5
									
								
								spec/models/group_removed_account_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec/models/group_removed_account_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  | 
 | ||||||
|  | RSpec.describe GroupRemovedAccount, type: :model do | ||||||
|  |   pending "add some examples to (or delete) #{__FILE__}" | ||||||
|  | end | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user