From d030783089d120a8a483db7d1f04a6a09615ca8a Mon Sep 17 00:00:00 2001 From: mgabdev <> Date: Thu, 10 Sep 2020 15:07:01 -0500 Subject: [PATCH] Added GroupPinnedStatuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added: - GroupPinnedStatuses - controllers for timeline, creation, deletion - redux actions, reducers for creation, deletion - timeline fetching in timelines action - options to pin, unpin in status options popover for group admin --- .../api/v1/groups/pins_controller.rb | 38 +++++++++ .../api/v1/timelines/group_controller.rb | 2 +- .../api/v1/timelines/group_pins_controller.rb | 39 +++++++++ app/javascript/gabsocial/actions/groups.js | 85 +++++++++++++++++++ app/javascript/gabsocial/actions/timelines.js | 1 + .../popover/status_options_popover.js | 57 +++++++++---- app/javascript/gabsocial/components/status.js | 6 +- .../gabsocial/components/status_list.js | 23 +++++ .../gabsocial/components/status_prepend.js | 9 +- .../gabsocial/features/group_timeline.js | 17 ++++ app/models/concerns/group_interactions.rb | 4 + app/models/group.rb | 3 + app/models/group_pinned_status.rb | 16 ++++ app/models/status.rb | 6 ++ .../status_relationships_presenter.rb | 9 +- app/serializers/rest/status_serializer.rb | 17 ++++ .../group_pinned_status_validator.rb | 10 +++ config/locales/en.yml | 5 ++ config/locales/en_GB.yml | 5 ++ config/routes.rb | 3 + 20 files changed, 332 insertions(+), 23 deletions(-) create mode 100644 app/controllers/api/v1/groups/pins_controller.rb create mode 100644 app/controllers/api/v1/timelines/group_pins_controller.rb create mode 100644 app/models/group_pinned_status.rb create mode 100644 app/validators/group_pinned_status_validator.rb diff --git a/app/controllers/api/v1/groups/pins_controller.rb b/app/controllers/api/v1/groups/pins_controller.rb new file mode 100644 index 00000000..850ba653 --- /dev/null +++ b/app/controllers/api/v1/groups/pins_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V1::Groups::PinsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:groups' } + before_action :require_user! + before_action :set_group + before_action :set_status + + respond_to :json + + def create + GroupPinnedStatus.create!(group: @group, status: @status) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + pin = GroupPinnedStatus.find_by(group: @group, status: @status) + + if pin + pin.destroy! + end + + render json: @status, serializer: REST::StatusSerializer + end + + private + + def set_status + @status = Status.find(params[:statusId]) + end + + def set_group + @group = Group.find(params[:group_id]) + end + +end diff --git a/app/controllers/api/v1/timelines/group_controller.rb b/app/controllers/api/v1/timelines/group_controller.rb index dd726d9f..e9c870a2 100644 --- a/app/controllers/api/v1/timelines/group_controller.rb +++ b/app/controllers/api/v1/timelines/group_controller.rb @@ -13,7 +13,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController if current_user render json: @statuses, each_serializer: REST::StatusSerializer, - relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id, group_id: @group.id) else render json: @statuses, each_serializer: REST::StatusSerializer end diff --git a/app/controllers/api/v1/timelines/group_pins_controller.rb b/app/controllers/api/v1/timelines/group_pins_controller.rb new file mode 100644 index 00000000..bf8353d9 --- /dev/null +++ b/app/controllers/api/v1/timelines/group_pins_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::GroupPinsController < Api::BaseController + before_action :set_group + before_action :set_statuses + + def show + if current_user + render json: @statuses, + each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id, group_id: @group.id) + else + render json: @statuses, each_serializer: REST::StatusSerializer + end + end + + private + + def set_group + @group = Group.where(id: params[:id], is_archived: false).first + end + + def set_statuses + @statuses = cached_group_statuses + end + + def cached_group_statuses + cache_collection group_statuses, Status + end + + def group_statuses + statuses = [] + @group.pinned_statuses.each do |s| + statuses << s + end + statuses + end + +end diff --git a/app/javascript/gabsocial/actions/groups.js b/app/javascript/gabsocial/actions/groups.js index 2a99f4d4..f273cd4b 100644 --- a/app/javascript/gabsocial/actions/groups.js +++ b/app/javascript/gabsocial/actions/groups.js @@ -63,6 +63,14 @@ export const GROUP_UPDATE_ROLE_REQUEST = 'GROUP_UPDATE_ROLE_REQUEST'; export const GROUP_UPDATE_ROLE_SUCCESS = 'GROUP_UPDATE_ROLE_SUCCESS'; export const GROUP_UPDATE_ROLE_FAIL = 'GROUP_UPDATE_ROLE_FAIL'; +export const GROUP_PIN_STATUS_REQUEST = 'GROUP_PIN_STATUS_REQUEST' +export const GROUP_PIN_STATUS_SUCCESS = 'GROUP_PIN_STATUS_SUCCESS' +export const GROUP_PIN_STATUS_FAIL = 'GROUP_PIN_STATUS_FAIL' + +export const GROUP_UNPIN_STATUS_REQUEST = 'GROUP_UNPIN_STATUS_REQUEST' +export const GROUP_UNPIN_STATUS_SUCCESS = 'GROUP_UNPIN_STATUS_SUCCESS' +export const GROUP_UNPIN_STATUS_FAIL = 'GROUP_UNPIN_STATUS_FAIL =' + export const GROUP_TIMELINE_SORT = 'GROUP_TIMELINE_SORT' export const GROUP_TIMELINE_TOP_SORT = 'GROUP_TIMELINE_TOP_SORT' @@ -587,6 +595,83 @@ export function updateRoleFail(groupId, id, error) { }; }; + +export function pinGroupStatus(groupId, statusId) { + return (dispatch, getState) => { + if (!me) return + + dispatch(pinGroupStatusRequest(groupId)) + + api(getState).post(`/api/v1/groups/${groupId}/pin`, { statusId }).then((response) => { + dispatch(pinGroupStatusSuccess(groupId, statusId)) + }).catch((error) => { + dispatch(pinGroupStatusFail(groupId, statusId, error)) + }) + } +} + +export function pinGroupStatusRequest(groupId) { + return { + type: GROUP_PIN_STATUS_REQUEST, + groupId, + } +} + +export function pinGroupStatusSuccess(groupId, statusId) { + return { + type: GROUP_PIN_STATUS_SUCCESS, + groupId, + statusId, + } +} + +export function pinGroupStatusFail(groupId, statusId, error) { + return { + type: GROUP_PIN_STATUS_FAIL, + groupId, + statusId, + error, + } +} + +export function unpinGroupStatus(groupId, statusId) { + return (dispatch, getState) => { + if (!me) return + + dispatch(unpinGroupStatusRequest(groupId)) + + api(getState).post(`/api/v1/groups/${groupId}/unpin`, { statusId }).then((response) => { + dispatch(unpinGroupStatusSuccess(groupId, statusId)) + }).catch((error) => { + dispatch(unpinGroupStatusFail(groupId, statusId, error)) + }) + } +} + +export function unpinGroupStatusRequest(groupId) { + return { + type: GROUP_UNPIN_STATUS_REQUEST, + groupId, + } +} + +export function unpinGroupStatusSuccess(groupId, statusId) { + return { + type: GROUP_UNPIN_STATUS_SUCCESS, + groupId, + statusId, + } +} + +export function unpinGroupStatusFail(groupId, statusId, error) { + return { + type: GROUP_UNPIN_STATUS_FAIL, + groupId, + statusId, + error, + } +} + export const sortGroups = (tab, sortType) => (dispatch, getState) => { const groupIdsByTab = getState().getIn(['group_lists', tab, 'items'], ImmutableList()).toJS() const allGroups = getState().get('groups', ImmutableMap()).toJS() diff --git a/app/javascript/gabsocial/actions/timelines.js b/app/javascript/gabsocial/actions/timelines.js index 798e0e54..7e61550c 100644 --- a/app/javascript/gabsocial/actions/timelines.js +++ b/app/javascript/gabsocial/actions/timelines.js @@ -173,6 +173,7 @@ export const expandAccountFeaturedTimeline = accountId => expandTimeline(`accoun export const expandAccountMediaTimeline = (accountId, { maxId, limit, mediaType } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: limit || 20, media_type: mediaType }); export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export const expandGroupTimeline = (id, { sortBy, maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { sort_by: sortBy, max_id: maxId, only_media: onlyMedia }, done); +export const expandGroupFeaturedTimeline = (groupId, done = noOp) => expandTimeline(`group:${groupId}:pinned`, `/api/v1/timelines/group_pins/${groupId}`, {}, done); export const expandGroupCollectionTimeline = (collectionType, { sortBy, maxId } = {}, done = noOp) => expandTimeline(`group_collection:${collectionType}`, `/api/v1/timelines/group_collection/${collectionType}`, { sort_by: sortBy, max_id: maxId }, done); export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { diff --git a/app/javascript/gabsocial/components/popover/status_options_popover.js b/app/javascript/gabsocial/components/popover/status_options_popover.js index 33d22f8c..aeb7a15e 100644 --- a/app/javascript/gabsocial/components/popover/status_options_popover.js +++ b/app/javascript/gabsocial/components/popover/status_options_popover.js @@ -24,6 +24,8 @@ import { fetchGroupRelationships, createRemovedAccount, groupRemoveStatus, + pinGroupStatus, + unpinGroupStatus, } from '../../actions/groups' import { initMuteModal } from '../../actions/mutes' import { initReport } from '../../actions/reports' @@ -86,6 +88,10 @@ class StatusOptionsPopover extends ImmutablePureComponent { this.props.onPin(this.props.status) } + handleGroupPinStatus = () => { + this.props.onPinGroupStatus(this.props.status) + } + handleBookmarkClick = () => { if (this.props.isPro) { this.props.onBookmark(this.props.status) @@ -230,6 +236,29 @@ class StatusOptionsPopover extends ImmutablePureComponent { } } + if (withGroupAdmin) { + menu.push(null) + menu.push({ + icon: 'trash', + hideArrow: true, + title: intl.formatMessage(messages.group_remove_account), + onClick: this.handleGroupRemoveAccount, + }) + menu.push({ + icon: 'trash', + hideArrow: true, + title: intl.formatMessage(messages.group_remove_post), + onClick: this.handleGroupRemovePost, + }) + menu.push(null) + menu.push({ + icon: 'pin', + hideArrow: true, + title: intl.formatMessage(status.get('pinned_by_group') ? messages.groupUnpin : messages.groupPin), + onClick: this.handleGroupPinStatus, + }) + } + menu.push(null) menu.push({ icon: 'copy', @@ -249,22 +278,6 @@ class StatusOptionsPopover extends ImmutablePureComponent { title: intl.formatMessage(messages.embed), onClick: this.handleOnOpenEmbedModal, }) - - if (withGroupAdmin) { - menu.push(null) - menu.push({ - icon: 'trash', - hideArrow: true, - title: intl.formatMessage(messages.group_remove_account), - onClick: this.handleGroupRemoveAccount, - }) - menu.push({ - icon: 'trash', - hideArrow: true, - title: intl.formatMessage(messages.group_remove_post), - onClick: this.handleGroupRemovePost, - }) - } if (isStaff) { menu.push(null) @@ -316,6 +329,8 @@ const messages = defineMessages({ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + groupPin: { id: 'status.group_pin', defaultMessage: 'Pin in group' }, + groupUnpin: { id: 'status.group_unpin', defaultMessage: 'Unpin from group' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark status' }, unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, @@ -471,6 +486,16 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(openModal(MODAL_PRO_UPGRADE)) }, + onPinGroupStatus(status) { + dispatch(closePopover()) + + if (status.get('pinned_by_group')) { + dispatch(unpinGroupStatus(status.getIn(['group', 'id']), status.get('id'))) + } else { + dispatch(pinGroupStatus(status.getIn(['group', 'id']), status.get('id'))) + } + }, + onClosePopover: () => dispatch(closePopover()), }) diff --git a/app/javascript/gabsocial/components/status.js b/app/javascript/gabsocial/components/status.js index 8e97c3c7..731a618a 100644 --- a/app/javascript/gabsocial/components/status.js +++ b/app/javascript/gabsocial/components/status.js @@ -82,6 +82,7 @@ class Status extends ImmutablePureComponent { 'isChild', 'isPromoted', 'isFeatured', + 'isPinnedInGroup', 'isMuted', 'isHidden', 'isIntersecting', @@ -311,6 +312,7 @@ class Status extends ImmutablePureComponent { const { intl, isFeatured, + isPinnedInGroup, isPromoted, isChild, isHidden, @@ -429,7 +431,7 @@ class Status extends ImmutablePureComponent {
@@ -564,6 +567,7 @@ Status.propTypes = { isChild: PropTypes.bool, isPromoted: PropTypes.bool, isFeatured: PropTypes.bool, + isPinnedInGroup: PropTypes.bool, isMuted: PropTypes.bool, isHidden: PropTypes.bool, isIntersecting: PropTypes.bool, diff --git a/app/javascript/gabsocial/components/status_list.js b/app/javascript/gabsocial/components/status_list.js index 9261ee69..de32e369 100644 --- a/app/javascript/gabsocial/components/status_list.js +++ b/app/javascript/gabsocial/components/status_list.js @@ -67,11 +67,18 @@ class StatusList extends ImmutablePureComponent { } getFeaturedStatusCount = () => { + if (!!this.props.groupPinnedStatusIds) { + return this.props.groupPinnedStatusIds.size + } + return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0 } getCurrentStatusIndex = (id, featured) => { if (featured) { + if (!!this.props.groupPinnedStatusIds) { + return this.props.groupPinnedStatusIds.indexOf(id) + } return this.props.featuredStatusIds.indexOf(id) } @@ -129,6 +136,7 @@ class StatusList extends ImmutablePureComponent { const { statusIds, featuredStatusIds, + groupPinnedStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, @@ -225,6 +233,20 @@ class StatusList extends ImmutablePureComponent { )).concat(scrollableContent) } + if (scrollableContent && groupPinnedStatusIds) { + scrollableContent = groupPinnedStatusIds.map((statusId) => ( +