Added GroupPinnedStatuses
• 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
This commit is contained in:
parent
899fe425d4
commit
d030783089
|
@ -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
|
|
@ -13,7 +13,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController
|
||||||
if current_user
|
if current_user
|
||||||
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, group_id: @group.id)
|
||||||
else
|
else
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -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_SUCCESS = 'GROUP_UPDATE_ROLE_SUCCESS';
|
||||||
export const GROUP_UPDATE_ROLE_FAIL = 'GROUP_UPDATE_ROLE_FAIL';
|
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_SORT = 'GROUP_TIMELINE_SORT'
|
||||||
export const GROUP_TIMELINE_TOP_SORT = 'GROUP_TIMELINE_TOP_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) => {
|
export const sortGroups = (tab, sortType) => (dispatch, getState) => {
|
||||||
const groupIdsByTab = getState().getIn(['group_lists', tab, 'items'], ImmutableList()).toJS()
|
const groupIdsByTab = getState().getIn(['group_lists', tab, 'items'], ImmutableList()).toJS()
|
||||||
const allGroups = getState().get('groups', ImmutableMap()).toJS()
|
const allGroups = getState().get('groups', ImmutableMap()).toJS()
|
||||||
|
|
|
@ -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 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 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 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 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) => {
|
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
|
||||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||||
|
|
|
@ -24,6 +24,8 @@ import {
|
||||||
fetchGroupRelationships,
|
fetchGroupRelationships,
|
||||||
createRemovedAccount,
|
createRemovedAccount,
|
||||||
groupRemoveStatus,
|
groupRemoveStatus,
|
||||||
|
pinGroupStatus,
|
||||||
|
unpinGroupStatus,
|
||||||
} from '../../actions/groups'
|
} from '../../actions/groups'
|
||||||
import { initMuteModal } from '../../actions/mutes'
|
import { initMuteModal } from '../../actions/mutes'
|
||||||
import { initReport } from '../../actions/reports'
|
import { initReport } from '../../actions/reports'
|
||||||
|
@ -86,6 +88,10 @@ class StatusOptionsPopover extends ImmutablePureComponent {
|
||||||
this.props.onPin(this.props.status)
|
this.props.onPin(this.props.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleGroupPinStatus = () => {
|
||||||
|
this.props.onPinGroupStatus(this.props.status)
|
||||||
|
}
|
||||||
|
|
||||||
handleBookmarkClick = () => {
|
handleBookmarkClick = () => {
|
||||||
if (this.props.isPro) {
|
if (this.props.isPro) {
|
||||||
this.props.onBookmark(this.props.status)
|
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(null)
|
||||||
menu.push({
|
menu.push({
|
||||||
icon: 'copy',
|
icon: 'copy',
|
||||||
|
@ -250,22 +279,6 @@ class StatusOptionsPopover extends ImmutablePureComponent {
|
||||||
onClick: this.handleOnOpenEmbedModal,
|
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) {
|
if (isStaff) {
|
||||||
menu.push(null)
|
menu.push(null)
|
||||||
|
|
||||||
|
@ -316,6 +329,8 @@ const messages = defineMessages({
|
||||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from 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' },
|
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark status' },
|
||||||
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
|
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
|
||||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||||
|
@ -471,6 +486,16 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
dispatch(openModal(MODAL_PRO_UPGRADE))
|
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()),
|
onClosePopover: () => dispatch(closePopover()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -82,6 +82,7 @@ class Status extends ImmutablePureComponent {
|
||||||
'isChild',
|
'isChild',
|
||||||
'isPromoted',
|
'isPromoted',
|
||||||
'isFeatured',
|
'isFeatured',
|
||||||
|
'isPinnedInGroup',
|
||||||
'isMuted',
|
'isMuted',
|
||||||
'isHidden',
|
'isHidden',
|
||||||
'isIntersecting',
|
'isIntersecting',
|
||||||
|
@ -311,6 +312,7 @@ class Status extends ImmutablePureComponent {
|
||||||
const {
|
const {
|
||||||
intl,
|
intl,
|
||||||
isFeatured,
|
isFeatured,
|
||||||
|
isPinnedInGroup,
|
||||||
isPromoted,
|
isPromoted,
|
||||||
isChild,
|
isChild,
|
||||||
isHidden,
|
isHidden,
|
||||||
|
@ -429,7 +431,7 @@ class Status extends ImmutablePureComponent {
|
||||||
<div
|
<div
|
||||||
className={[_s.d, _s.outlineNone].join(' ')}
|
className={[_s.d, _s.outlineNone].join(' ')}
|
||||||
tabIndex={this.props.isMuted ? null : 0}
|
tabIndex={this.props.isMuted ? null : 0}
|
||||||
data-featured={isFeatured ? 'true' : null}
|
data-featured={(isFeatured || isPinnedInGroup) ? 'true' : null}
|
||||||
aria-label={textForScreenReader(intl, status, rebloggedByText)}
|
aria-label={textForScreenReader(intl, status, rebloggedByText)}
|
||||||
ref={this.handleRef}
|
ref={this.handleRef}
|
||||||
onClick={isChild && !isNotification ? this.handleClick : undefined}
|
onClick={isChild && !isNotification ? this.handleClick : undefined}
|
||||||
|
@ -442,6 +444,7 @@ class Status extends ImmutablePureComponent {
|
||||||
status={this.props.status}
|
status={this.props.status}
|
||||||
isPromoted={isPromoted}
|
isPromoted={isPromoted}
|
||||||
isFeatured={isFeatured}
|
isFeatured={isFeatured}
|
||||||
|
isPinnedInGroup={isPinnedInGroup}
|
||||||
isComment={isComment && !isChild}
|
isComment={isComment && !isChild}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -564,6 +567,7 @@ Status.propTypes = {
|
||||||
isChild: PropTypes.bool,
|
isChild: PropTypes.bool,
|
||||||
isPromoted: PropTypes.bool,
|
isPromoted: PropTypes.bool,
|
||||||
isFeatured: PropTypes.bool,
|
isFeatured: PropTypes.bool,
|
||||||
|
isPinnedInGroup: PropTypes.bool,
|
||||||
isMuted: PropTypes.bool,
|
isMuted: PropTypes.bool,
|
||||||
isHidden: PropTypes.bool,
|
isHidden: PropTypes.bool,
|
||||||
isIntersecting: PropTypes.bool,
|
isIntersecting: PropTypes.bool,
|
||||||
|
|
|
@ -67,11 +67,18 @@ class StatusList extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
getFeaturedStatusCount = () => {
|
getFeaturedStatusCount = () => {
|
||||||
|
if (!!this.props.groupPinnedStatusIds) {
|
||||||
|
return this.props.groupPinnedStatusIds.size
|
||||||
|
}
|
||||||
|
|
||||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0
|
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentStatusIndex = (id, featured) => {
|
getCurrentStatusIndex = (id, featured) => {
|
||||||
if (featured) {
|
if (featured) {
|
||||||
|
if (!!this.props.groupPinnedStatusIds) {
|
||||||
|
return this.props.groupPinnedStatusIds.indexOf(id)
|
||||||
|
}
|
||||||
return this.props.featuredStatusIds.indexOf(id)
|
return this.props.featuredStatusIds.indexOf(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,6 +136,7 @@ class StatusList extends ImmutablePureComponent {
|
||||||
const {
|
const {
|
||||||
statusIds,
|
statusIds,
|
||||||
featuredStatusIds,
|
featuredStatusIds,
|
||||||
|
groupPinnedStatusIds,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
timelineId,
|
timelineId,
|
||||||
totalQueuedItemsCount,
|
totalQueuedItemsCount,
|
||||||
|
@ -225,6 +233,20 @@ class StatusList extends ImmutablePureComponent {
|
||||||
)).concat(scrollableContent)
|
)).concat(scrollableContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scrollableContent && groupPinnedStatusIds) {
|
||||||
|
scrollableContent = groupPinnedStatusIds.map((statusId) => (
|
||||||
|
<StatusContainer
|
||||||
|
key={`f-${statusId}`}
|
||||||
|
id={statusId}
|
||||||
|
isPinnedInGroup
|
||||||
|
onMoveUp={this.handleMoveUp}
|
||||||
|
onMoveDown={this.handleMoveDown}
|
||||||
|
contextType={timelineId}
|
||||||
|
commentsLimited
|
||||||
|
/>
|
||||||
|
)).concat(scrollableContent)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<TimelineQueueButtonHeader
|
<TimelineQueueButtonHeader
|
||||||
|
@ -326,6 +348,7 @@ StatusList.propTypes = {
|
||||||
scrollKey: PropTypes.string.isRequired,
|
scrollKey: PropTypes.string.isRequired,
|
||||||
statusIds: ImmutablePropTypes.list.isRequired,
|
statusIds: ImmutablePropTypes.list.isRequired,
|
||||||
featuredStatusIds: ImmutablePropTypes.list,
|
featuredStatusIds: ImmutablePropTypes.list,
|
||||||
|
groupPinnedStatusIds: ImmutablePropTypes.list,
|
||||||
onLoadMore: PropTypes.func,
|
onLoadMore: PropTypes.func,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
isPartial: PropTypes.bool,
|
isPartial: PropTypes.bool,
|
||||||
|
|
|
@ -16,16 +16,17 @@ class StatusPrepend extends ImmutablePureComponent {
|
||||||
isFeatured,
|
isFeatured,
|
||||||
isPromoted,
|
isPromoted,
|
||||||
isComment,
|
isComment,
|
||||||
|
isPinnedInGroup,
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
if (!status) return null
|
if (!status) return null
|
||||||
|
|
||||||
const isRepost = (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object')
|
const isRepost = (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object')
|
||||||
|
|
||||||
if (!isFeatured && !isPromoted && !isRepost && !isComment) return null
|
if (!isFeatured && !isPinnedInGroup && !isPromoted && !isRepost && !isComment) return null
|
||||||
|
|
||||||
let iconId
|
let iconId
|
||||||
if (isFeatured) iconId = 'pin'
|
if (isFeatured || isPinnedInGroup) iconId = 'pin'
|
||||||
else if (isPromoted) iconId = 'star'
|
else if (isPromoted) iconId = 'star'
|
||||||
else if (isRepost) iconId = 'repost'
|
else if (isRepost) iconId = 'repost'
|
||||||
else if (isComment) iconId = 'comment'
|
else if (isComment) iconId = 'comment'
|
||||||
|
@ -62,7 +63,7 @@ class StatusPrepend extends ImmutablePureComponent {
|
||||||
{
|
{
|
||||||
!isRepost && !isComment &&
|
!isRepost && !isComment &&
|
||||||
<Text color='secondary' size='small'>
|
<Text color='secondary' size='small'>
|
||||||
{intl.formatMessage(isFeatured ? messages.pinned : messages.promoted)}
|
{intl.formatMessage(isFeatured ? messages.pinned : isPinnedInGroup ? messages.pinnedByGroup : messages.promoted)}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
@ -99,6 +100,7 @@ const messages = defineMessages({
|
||||||
filtered: { id: 'status.filtered', defaultMessage: 'Filtered' },
|
filtered: { id: 'status.filtered', defaultMessage: 'Filtered' },
|
||||||
promoted: { id: 'status.promoted', defaultMessage: 'Promoted gab' },
|
promoted: { id: 'status.promoted', defaultMessage: 'Promoted gab' },
|
||||||
pinned: { id: 'status.pinned', defaultMessage: 'Pinned gab' },
|
pinned: { id: 'status.pinned', defaultMessage: 'Pinned gab' },
|
||||||
|
pinnedByGroup: { id: 'status.pinned_by_group', defaultMessage: 'Pinned to group' },
|
||||||
reposted: { id: 'status.reposted_by', defaultMessage: '{name} reposted' },
|
reposted: { id: 'status.reposted_by', defaultMessage: '{name} reposted' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -107,6 +109,7 @@ StatusPrepend.propTypes = {
|
||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
isComment: PropTypes.bool,
|
isComment: PropTypes.bool,
|
||||||
isFeatured: PropTypes.bool,
|
isFeatured: PropTypes.bool,
|
||||||
|
isPinnedInGroup: PropTypes.bool,
|
||||||
isPromoted: PropTypes.bool,
|
isPromoted: PropTypes.bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
|
||||||
import { connect } from 'react-redux'
|
import { connect } from 'react-redux'
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component'
|
import ImmutablePureComponent from 'react-immutable-pure-component'
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes'
|
import ImmutablePropTypes from 'react-immutable-proptypes'
|
||||||
|
import { List as ImmutableList } from 'immutable'
|
||||||
import { injectIntl, defineMessages } from 'react-intl'
|
import { injectIntl, defineMessages } from 'react-intl'
|
||||||
import { me } from '../initial_state'
|
import { me } from '../initial_state'
|
||||||
import getSortBy from '../utils/group_sort_by'
|
import getSortBy from '../utils/group_sort_by'
|
||||||
|
@ -10,6 +11,7 @@ import { connectGroupStream } from '../actions/streaming'
|
||||||
import {
|
import {
|
||||||
clearTimeline,
|
clearTimeline,
|
||||||
expandGroupTimeline,
|
expandGroupTimeline,
|
||||||
|
expandGroupFeaturedTimeline,
|
||||||
} from '../actions/timelines'
|
} from '../actions/timelines'
|
||||||
import {
|
import {
|
||||||
setGroupTimelineSort,
|
setGroupTimelineSort,
|
||||||
|
@ -41,6 +43,8 @@ class GroupTimeline extends ImmutablePureComponent {
|
||||||
this.props.setMemberNewest()
|
this.props.setMemberNewest()
|
||||||
} else {
|
} else {
|
||||||
const sortBy = getSortBy(sortByValue, sortByTopValue, onlyMedia)
|
const sortBy = getSortBy(sortByValue, sortByTopValue, onlyMedia)
|
||||||
|
|
||||||
|
this.props.onExpandGroupFeaturedTimeline(groupId)
|
||||||
this.props.onExpandGroupTimeline(groupId, { sortBy, onlyMedia })
|
this.props.onExpandGroupTimeline(groupId, { sortBy, onlyMedia })
|
||||||
|
|
||||||
if (!!me) {
|
if (!!me) {
|
||||||
|
@ -57,6 +61,10 @@ class GroupTimeline extends ImmutablePureComponent {
|
||||||
this.handleLoadMore()
|
this.handleLoadMore()
|
||||||
this.props.onClearTimeline(`group:${this.props.groupId}`)
|
this.props.onClearTimeline(`group:${this.props.groupId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prevProps.groupId !== this.props.groupId) {
|
||||||
|
this.props.onExpandGroupFeaturedTimeline(this.props.groupId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
@ -83,6 +91,7 @@ class GroupTimeline extends ImmutablePureComponent {
|
||||||
group,
|
group,
|
||||||
groupId,
|
groupId,
|
||||||
intl,
|
intl,
|
||||||
|
groupPinnedStatusIds,
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
if (typeof group === 'undefined') {
|
if (typeof group === 'undefined') {
|
||||||
|
@ -98,6 +107,7 @@ class GroupTimeline extends ImmutablePureComponent {
|
||||||
scrollKey={`group-timeline-${groupId}`}
|
scrollKey={`group-timeline-${groupId}`}
|
||||||
timelineId={`group:${groupId}`}
|
timelineId={`group:${groupId}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
|
groupPinnedStatusIds={groupPinnedStatusIds}
|
||||||
emptyMessage={intl.formatMessage(messages.empty)}
|
emptyMessage={intl.formatMessage(messages.empty)}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
@ -110,9 +120,12 @@ const messages = defineMessages({
|
||||||
empty: { id: 'empty_column.group', defaultMessage: 'There is nothing in this group yet.\nWhen members of this group post new statuses, they will appear here.' },
|
empty: { id: 'empty_column.group', defaultMessage: 'There is nothing in this group yet.\nWhen members of this group post new statuses, they will appear here.' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emptyList = ImmutableList()
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
groupId: props.params.id,
|
groupId: props.params.id,
|
||||||
group: state.getIn(['groups', props.params.id]),
|
group: state.getIn(['groups', props.params.id]),
|
||||||
|
groupPinnedStatusIds: state.getIn(['timelines', `group:${props.params.id}:pinned`, 'items'], emptyList),
|
||||||
sortByValue: state.getIn(['group_lists', 'sortByValue']),
|
sortByValue: state.getIn(['group_lists', 'sortByValue']),
|
||||||
sortByTopValue: state.getIn(['group_lists', 'sortByTopValue']),
|
sortByTopValue: state.getIn(['group_lists', 'sortByTopValue']),
|
||||||
})
|
})
|
||||||
|
@ -130,6 +143,9 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
setMemberNewest() {
|
setMemberNewest() {
|
||||||
dispatch(setGroupTimelineSort(GROUP_TIMELINE_SORTING_TYPE_NEWEST))
|
dispatch(setGroupTimelineSort(GROUP_TIMELINE_SORTING_TYPE_NEWEST))
|
||||||
},
|
},
|
||||||
|
onExpandGroupFeaturedTimeline(groupId) {
|
||||||
|
dispatch(expandGroupFeaturedTimeline(groupId))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
GroupTimeline.propTypes = {
|
GroupTimeline.propTypes = {
|
||||||
|
@ -143,6 +159,7 @@ GroupTimeline.propTypes = {
|
||||||
onConnectGroupStream: PropTypes.func.isRequired,
|
onConnectGroupStream: PropTypes.func.isRequired,
|
||||||
onClearTimeline: PropTypes.func.isRequired,
|
onClearTimeline: PropTypes.func.isRequired,
|
||||||
onExpandGroupTimeline: PropTypes.func.isRequired,
|
onExpandGroupTimeline: PropTypes.func.isRequired,
|
||||||
|
onExpandGroupFeaturedTimeline: PropTypes.func.isRequired,
|
||||||
setMemberNewest: PropTypes.func.isRequired,
|
setMemberNewest: PropTypes.func.isRequired,
|
||||||
sortByValue: PropTypes.string.isRequired,
|
sortByValue: PropTypes.string.isRequired,
|
||||||
sortByTopValue: PropTypes.string,
|
sortByTopValue: PropTypes.string,
|
||||||
|
|
|
@ -17,6 +17,10 @@ module GroupInteractions
|
||||||
follow_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id, role: :moderator), :group_id)
|
follow_mapping(GroupAccount.where(group_id: target_group_ids, account_id: account_id, role: :moderator), :group_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def pinned?(status)
|
||||||
|
group_pinned_statuses.where(group_id: status.group_id, status: status).exists?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def follow_mapping(query, field)
|
def follow_mapping(query, field)
|
||||||
|
|
|
@ -36,6 +36,9 @@ 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_pinned_statuses, inverse_of: :group, dependent: :destroy
|
||||||
|
has_many :pinned_statuses, source: :status, through: :group_pinned_statuses
|
||||||
|
|
||||||
has_many :group_removed_accounts, inverse_of: :group, dependent: :destroy
|
has_many :group_removed_accounts, inverse_of: :group, dependent: :destroy
|
||||||
has_many :removed_accounts, source: :account, through: :group_removed_accounts
|
has_many :removed_accounts, source: :account, through: :group_removed_accounts
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: group_pinned_statuses
|
||||||
|
#
|
||||||
|
# id :bigint(8) not null, primary key
|
||||||
|
# status_id :bigint(8) not null
|
||||||
|
# group_id :bigint(8) not null
|
||||||
|
#
|
||||||
|
|
||||||
|
class GroupPinnedStatus < ApplicationRecord
|
||||||
|
belongs_to :group
|
||||||
|
belongs_to :status
|
||||||
|
|
||||||
|
validates_with GroupPinnedStatusValidator
|
||||||
|
end
|
|
@ -389,6 +389,12 @@ class Status < ApplicationRecord
|
||||||
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def group_pins_map(status_ids, group_id = nil)
|
||||||
|
unless group_id.nil?
|
||||||
|
GroupPinnedStatus.select('status_id').where(status_id: status_ids).where(group_id: group_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def reload_stale_associations!(cached_items)
|
def reload_stale_associations!(cached_items)
|
||||||
account_ids = []
|
account_ids = []
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class StatusRelationshipsPresenter
|
class StatusRelationshipsPresenter
|
||||||
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, :bookmarks_map
|
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, :group_pins_map, :bookmarks_map
|
||||||
|
|
||||||
|
|
||||||
def initialize(statuses, current_account_id = nil, **options)
|
def initialize(statuses, current_account_id = nil, **options)
|
||||||
if current_account_id.nil?
|
if current_account_id.nil?
|
||||||
|
@ -11,6 +10,7 @@ class StatusRelationshipsPresenter
|
||||||
@mutes_map = {}
|
@mutes_map = {}
|
||||||
@bookmarks_map = {}
|
@bookmarks_map = {}
|
||||||
@pins_map = {}
|
@pins_map = {}
|
||||||
|
@group_pins_map = {}
|
||||||
else
|
else
|
||||||
statuses = statuses.compact
|
statuses = statuses.compact
|
||||||
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id, s.quote_of_id] }.uniq.compact
|
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id, s.quote_of_id] }.uniq.compact
|
||||||
|
@ -22,6 +22,11 @@ class StatusRelationshipsPresenter
|
||||||
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
|
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
|
||||||
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
|
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
|
||||||
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
|
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
|
||||||
|
if options[:group_id]
|
||||||
|
@group_pins_map = Status.group_pins_map(status_ids, options[:group_id]).merge(options[:group_pins_map] || {})
|
||||||
|
else
|
||||||
|
@group_pins_map = {}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
attribute :muted, if: :current_user?
|
attribute :muted, if: :current_user?
|
||||||
attribute :bookmarked, if: :current_user?
|
attribute :bookmarked, if: :current_user?
|
||||||
attribute :pinned, if: :pinnable?
|
attribute :pinned, if: :pinnable?
|
||||||
|
attribute :pinned_by_group, if: :pinnable_by_group?
|
||||||
|
|
||||||
attribute :content, unless: :source_requested?
|
attribute :content, unless: :source_requested?
|
||||||
attribute :rich_content, unless: :source_requested?
|
attribute :rich_content, unless: :source_requested?
|
||||||
|
@ -141,6 +142,22 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
%w(public unlisted).include?(object.visibility)
|
%w(public unlisted).include?(object.visibility)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def pinned_by_group
|
||||||
|
if instance_options && instance_options[:relationships]
|
||||||
|
instance_options[:relationships].group_pins_map[object.id] || false
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def pinnable_by_group?
|
||||||
|
if object.group_id?
|
||||||
|
true
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def source_requested?
|
def source_requested?
|
||||||
instance_options[:source_requested]
|
instance_options[:source_requested]
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class GroupPinnedStatusValidator < ActiveModel::Validator
|
||||||
|
def validate(groupPin)
|
||||||
|
groupPin.errors.add(:base, I18n.t('statuses.group_pin_errors.reblog')) if groupPin.status.reblog?
|
||||||
|
groupPin.errors.add(:base, I18n.t('statuses.pin_errors.ungrouped')) unless groupPin.status.group_id
|
||||||
|
groupPin.errors.add(:base, I18n.t('statuses.pin_errors.notGroupStatus')) if groupPin.status.group_id != groupPin.group.id
|
||||||
|
groupPin.errors.add(:base, I18n.t('statuses.group_pin_errors.limit')) if groupPin.group.group_pinned_statuses.count >= 4
|
||||||
|
end
|
||||||
|
end
|
|
@ -933,6 +933,11 @@ en:
|
||||||
ownership: Someone else's gab cannot be pinned
|
ownership: Someone else's gab cannot be pinned
|
||||||
private: Non-public gabs can't be pinned
|
private: Non-public gabs can't be pinned
|
||||||
reblog: A repost cannot be pinned
|
reblog: A repost cannot be pinned
|
||||||
|
group_pin_errors:
|
||||||
|
limit: You have already pinned the maximum number of gabs for groups
|
||||||
|
ungrouped: Gabs not made in the group cannot be pinned
|
||||||
|
reblog: A repost cannot be pinned
|
||||||
|
notGroupStatus: The status you are attempting to pin is not associated with the given group
|
||||||
poll:
|
poll:
|
||||||
total_votes:
|
total_votes:
|
||||||
one: "%{count} vote"
|
one: "%{count} vote"
|
||||||
|
|
|
@ -873,6 +873,11 @@ en_GB:
|
||||||
ownership: Someone else's toot cannot be pinned
|
ownership: Someone else's toot cannot be pinned
|
||||||
private: Non-public toot cannot be pinned
|
private: Non-public toot cannot be pinned
|
||||||
reblog: A repost cannot be pinned
|
reblog: A repost cannot be pinned
|
||||||
|
group_pin_errors:
|
||||||
|
limit: You have already pinned the maximum number of gabs for groups
|
||||||
|
ungrouped: Gabs not made in the group cannot be pinned
|
||||||
|
reblog: A repost cannot be pinned
|
||||||
|
notGroupStatus: The status you are attempting to pin is not associated with the given group
|
||||||
poll:
|
poll:
|
||||||
total_votes:
|
total_votes:
|
||||||
one: "%{count} vote"
|
one: "%{count} vote"
|
||||||
|
|
|
@ -330,6 +330,7 @@ Rails.application.routes.draw do
|
||||||
resources :list, only: :show
|
resources :list, only: :show
|
||||||
resources :group, only: :show
|
resources :group, only: :show
|
||||||
resources :group_collection, only: :show
|
resources :group_collection, only: :show
|
||||||
|
resources :group_pins, only: :show
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :gab_trends, only: [:index]
|
resources :gab_trends, only: [:index]
|
||||||
|
@ -439,6 +440,8 @@ 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'
|
resource :removed_accounts, only: [:show, :create, :destroy], controller: 'groups/removed_accounts'
|
||||||
|
resource :pin, only: :create, controller: 'groups/pins'
|
||||||
|
post :unpin, to: 'groups/pins#destroy'
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :polls, only: [:create, :show] do
|
resources :polls, only: [:create, :show] do
|
||||||
|
|
Loading…
Reference in New Issue