Merge branch 'feature/promoted-gabs' of https://code.gab.com/gab/social/gab-social into develop

This commit is contained in:
Rob Colbert
2019-09-03 18:38:37 -04:00
17 changed files with 226 additions and 22 deletions

View File

@@ -0,0 +1,56 @@
class Settings::PromotionsController < Admin::BaseController
before_action :set_promotion, except: [:index, :new, :create]
def index
@promotions = Promotion.all
end
def new
@promotion = Promotion.new
end
def create
@promotion = Promotion.new(resource_params)
if @promotion.save
log_action :create, @promotion
redirect_to settings_promotions_path, notice: I18n.t('promotions.created_msg')
else
render :new
end
end
def edit
end
def update
if @promotion.update(resource_params)
log_action :update, @promotion
flash[:notice] = I18n.t('promotions.updated_msg')
else
flash[:alert] = I18n.t('promotions.update_failed_msg')
end
redirect_to settings_promotions_path
end
def destroy
@promotion.destroy!
log_action :destroy, @promotion
flash[:notice] = I18n.t('promotions.destroyed_msg')
redirect_to settings_promotions_path
end
private
def set_promotion
@promotion = Promotion.find(params[:id])
end
def set_filter_params
@filter_params = filter_params.to_hash.symbolize_keys
end
def resource_params
params.require(:promotion).permit(:expires_at, :status_id, :timeline_id, :position)
end
end

View File

@@ -92,6 +92,7 @@ class Status extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
group: ImmutablePropTypes.map,
promoted: PropTypes.bool
};
// Avoid checking props that are functions (and whose equality will always
@@ -261,7 +262,7 @@ class Status extends ImmutablePureComponent {
let media = null;
let statusAvatar, prepend, rebloggedByText, reblogContent;
const { intl, hidden, featured, otherAccounts, unread, showThread, group } = this.props;
const { intl, hidden, featured, otherAccounts, unread, showThread, group, promoted } = this.props;
let { status, account, ...other } = this.props;
@@ -293,7 +294,14 @@ class Status extends ImmutablePureComponent {
);
}
if (featured) {
if (promoted) {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='star' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.promoted' defaultMessage='Promoted gab' />
</div>
);
} else if (featured) {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>

View File

@@ -29,12 +29,24 @@ export default class StatusList extends ImmutablePureComponent {
withGroupAdmin: PropTypes.bool,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
promotion: PropTypes.object,
promotedStatus: ImmutablePropTypes.map,
fetchStatus: PropTypes.func,
};
componentDidMount() {
this.handleDequeueTimeline();
this.fetchPromotedStatus();
};
fetchPromotedStatus() {
const { promotion, promotedStatus, fetchStatus } = this.props;
if (promotion && !promotedStatus) {
fetchStatus(promotion.status_id);
}
}
getFeaturedStatusCount = () => {
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
}
@@ -86,7 +98,7 @@ export default class StatusList extends ImmutablePureComponent {
}
render () {
const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props;
const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, promotion, promotedStatus, ...other } = this.props;
if (isPartial) {
return (
@@ -110,16 +122,26 @@ export default class StatusList extends ImmutablePureComponent {
onClick={onLoadMore}
/>
) : (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
group={group}
withGroupAdmin={withGroupAdmin}
showThread
/>
<React.Fragment key={statusId}>
<StatusContainer
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
group={group}
withGroupAdmin={withGroupAdmin}
showThread
/>
{promotedStatus && index === promotion.position && (
<StatusContainer
id={promotion.status_id}
contextType={timelineId}
promoted
showThread
/>
)}
</React.Fragment>
))
) : null;

View File

@@ -3,9 +3,11 @@ import StatusList from '../../../components/status_list';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect';
import { debounce } from 'lodash';
import { me } from '../../../initial_state';
import { me, promotions } from '../../../initial_state';
import { dequeueTimeline } from 'gabsocial/actions/timelines';
import { scrollTopTimeline } from '../../../actions/timelines';
import { sample } from 'lodash';
import { fetchStatus } from '../../../actions/statuses';
const makeGetStatusIds = () => createSelector([
(state, { type, id }) => state.getIn(['settings', type], ImmutableMap()),
@@ -32,6 +34,7 @@ const makeGetStatusIds = () => createSelector([
const mapStateToProps = (state, {timelineId}) => {
const getStatusIds = makeGetStatusIds();
const promotion = promotions.length > 0 && sample(promotions.filter(p => p.timeline_id === timelineId));
return {
statusIds: getStatusIds(state, { type: timelineId.substring(0,5) === 'group' ? 'group' : timelineId, id: timelineId }),
@@ -39,6 +42,8 @@ const mapStateToProps = (state, {timelineId}) => {
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
hasMore: state.getIn(['timelines', timelineId, 'hasMore']),
totalQueuedItemsCount: state.getIn(['timelines', timelineId, 'totalQueuedItemsCount']),
promotion: promotion,
promotedStatus: promotion && state.getIn(['statuses', promotion.status_id])
};
};
@@ -52,6 +57,9 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
onScroll: debounce(() => {
dispatch(scrollTopTimeline(ownProps.timelineId, false));
}, 100),
fetchStatus(id) {
dispatch(fetchStatus(id));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(StatusList);

View File

@@ -23,5 +23,6 @@ export const mascot = getMeta('mascot');
export const profile_directory = getMeta('profile_directory');
export const isStaff = getMeta('is_staff');
export const forceSingleColumn = !getMeta('advanced_layout');
export const promotions = initialState && initialState.promotions;
export default initialState;

18
app/models/promotion.rb Normal file
View File

@@ -0,0 +1,18 @@
# == Schema Information
#
# Table name: promotions
#
# id :bigint(8) not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# expires_at :datetime
# status_id :bigint(8) not null
# timeline_id :string
# position :integer default(10)
#
class Promotion < ApplicationRecord
belongs_to :status
scope :active, -> { where('expires_at > ?', [Time.now]) }
end

View File

@@ -2,7 +2,8 @@
class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts,
:media_attachments, :settings
:media_attachments, :settings,
:promotions
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
@@ -65,6 +66,10 @@ class InitialStateSerializer < ActiveModel::Serializer
{ accept_content_types: MediaAttachment::IMAGE_FILE_EXTENSIONS + MediaAttachment::VIDEO_FILE_EXTENSIONS + MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES }
end
def promotions
ActiveModelSerializers::SerializableResource.new(Promotion.active, each_serializer: REST::PromotionSerializer)
end
private
def instance_presenter

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::PromotionSerializer < ActiveModel::Serializer
attributes :status_id, :timeline_id, :position
def status_id
object.status_id.to_s
end
end

View File

@@ -0,0 +1,8 @@
%tr
%td= promotion.timeline_id
%td= promotion.status_id
%td= promotion.expires_at
%td= promotion.position
%td
= table_link_to 'pencil', t('promotions.edit'), edit_settings_promotion_path(promotion)
= table_link_to 'trash', t('promotions.delete'), settings_promotion_path(promotion), method: :delete, data: { confirm: t('settings.promotions.are_you_sure') }

View File

@@ -0,0 +1,14 @@
- content_for :page_title do
= t('promotions.title')
= simple_form_for @promotion, url: settings_promotion_path(@promotion) do |f|
= render 'shared/error_messages', object: @promotion
.fields-group
= f.input :timeline_id, wrapper: :with_label, label: t('promotions.timeline_id')
= f.input :status_id, wrapper: :with_label, label: t('promotions.status_id')
= f.input :expires_at, as: :string, wrapper: :with_label, label: t('promotions.expires_at')
= f.input :position, wrapper: :with_label, label: t('promotions.position')
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View File

@@ -0,0 +1,16 @@
- content_for :page_title do
= t('promotions.title')
.table-wrapper
%table.table
%thead
%tr
%th= t('promotions.timeline_id')
%th= t('promotions.status_id')
%th= t('promotions.expires_at')
%th= t('promotions.position')
%th
%tbody
= render @promotions
= link_to t('promotions.create'), new_settings_promotion_path, class: 'button'

View File

@@ -0,0 +1,14 @@
- content_for :page_title do
= t('.title')
= simple_form_for @promotion, url: settings_promotions_path do |f|
= render 'shared/error_messages', object: @promotion
.fields-group
= f.input :timeline_id, wrapper: :with_label, label: t('promotions.timeline_id')
= f.input :status_id, wrapper: :with_label, label: t('promotions.status_id')
= f.input :expires_at, as: :string, wrapper: :with_label, label: t('promotions.expires_at')
= f.input :position, wrapper: :with_label, label: t('promotions.position')
.actions
= f.button :button, t('.create'), type: :submit