diff --git a/app/controllers/settings/promotions_controller.rb b/app/controllers/settings/promotions_controller.rb new file mode 100644 index 00000000..147e6131 --- /dev/null +++ b/app/controllers/settings/promotions_controller.rb @@ -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 diff --git a/app/javascript/gabsocial/components/status.js b/app/javascript/gabsocial/components/status.js index 715e887c..4e8fde9e 100644 --- a/app/javascript/gabsocial/components/status.js +++ b/app/javascript/gabsocial/components/status.js @@ -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 = ( +
+
+ +
+ ); + } else if (featured) { prepend = (
diff --git a/app/javascript/gabsocial/components/status_list.js b/app/javascript/gabsocial/components/status_list.js index b1ed75d6..83dd405b 100644 --- a/app/javascript/gabsocial/components/status_list.js +++ b/app/javascript/gabsocial/components/status_list.js @@ -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} /> ) : ( - + + + + {promotedStatus && index === promotion.position && ( + + )} + )) ) : null; diff --git a/app/javascript/gabsocial/features/ui/containers/status_list_container.js b/app/javascript/gabsocial/features/ui/containers/status_list_container.js index 959b61a7..77c6b2e3 100644 --- a/app/javascript/gabsocial/features/ui/containers/status_list_container.js +++ b/app/javascript/gabsocial/features/ui/containers/status_list_container.js @@ -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); diff --git a/app/javascript/gabsocial/initial_state.js b/app/javascript/gabsocial/initial_state.js index 315abc4e..07107232 100644 --- a/app/javascript/gabsocial/initial_state.js +++ b/app/javascript/gabsocial/initial_state.js @@ -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; diff --git a/app/models/promotion.rb b/app/models/promotion.rb new file mode 100644 index 00000000..6ae948b6 --- /dev/null +++ b/app/models/promotion.rb @@ -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 diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 5226da10..84c26889 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -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 diff --git a/app/serializers/rest/promotion_serializer.rb b/app/serializers/rest/promotion_serializer.rb new file mode 100644 index 00000000..ad844d39 --- /dev/null +++ b/app/serializers/rest/promotion_serializer.rb @@ -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 diff --git a/app/views/settings/promotions/_promotion.html.haml b/app/views/settings/promotions/_promotion.html.haml new file mode 100644 index 00000000..73833555 --- /dev/null +++ b/app/views/settings/promotions/_promotion.html.haml @@ -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') } diff --git a/app/views/settings/promotions/edit.html.haml b/app/views/settings/promotions/edit.html.haml new file mode 100644 index 00000000..ccfeb379 --- /dev/null +++ b/app/views/settings/promotions/edit.html.haml @@ -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 diff --git a/app/views/settings/promotions/index.html.haml b/app/views/settings/promotions/index.html.haml new file mode 100644 index 00000000..5cbbec30 --- /dev/null +++ b/app/views/settings/promotions/index.html.haml @@ -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' diff --git a/app/views/settings/promotions/new.html.haml b/app/views/settings/promotions/new.html.haml new file mode 100644 index 00000000..8e73e308 --- /dev/null +++ b/app/views/settings/promotions/new.html.haml @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 033a952f..8d0a85c3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -70,6 +70,8 @@ en: moderator: Mod unavailable: Profile unavailable unfollow: Unfollow + promotions: + title: Promotions admin: account_actions: action: Perform action diff --git a/config/navigation.rb b/config/navigation.rb index 531a19b3..3282b6e5 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -55,6 +55,7 @@ SimpleNavigation::Configuration.run do |navigation| s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? } s.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? } s.item :moderation, safe_join([fa_icon('id-card-o fw'), t('verifications.moderation.title')]), settings_verifications_moderation_url, if: -> { current_user.admin? } + s.item :promotions, safe_join([fa_icon('star fw'), t('promotions.title')]), settings_promotions_url, if: -> { current_user.admin? } end n.item :logout, safe_join([fa_icon('sign-out fw'), t('auth.logout')]), destroy_user_session_url, link_html: { 'data-method' => 'delete' } diff --git a/config/routes.rb b/config/routes.rb index 4c296825..a64bd20b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,8 @@ Rails.application.routes.draw do post '/btcpay-notification', to: 'upgrade#btcpay_notification', as: :btcpay_notification end + resources :promotions, only: [:index, :new, :create, :edit, :update, :destroy] + namespace :verifications do get :moderation, to: 'moderation#index', as: :moderation get 'moderation/:id/approve', to: 'moderation#approve', as: :approve @@ -236,7 +238,7 @@ Rails.application.routes.draw do resources :users, only: [] do resource :two_factor_authentication, only: [:destroy] end - + resources :custom_emojis, only: [:index, :new, :create, :update, :destroy] do member do post :copy diff --git a/db/migrate/20190903162122_create_promotions.rb b/db/migrate/20190903162122_create_promotions.rb new file mode 100644 index 00000000..1adea431 --- /dev/null +++ b/db/migrate/20190903162122_create_promotions.rb @@ -0,0 +1,11 @@ +class CreatePromotions < ActiveRecord::Migration[5.2] + def change + create_table :promotions do |t| + t.timestamps + t.datetime :expires_at, null: true + t.bigint :status_id, null: false + t.string :timeline_id, null: true + t.integer :position, default: 10 + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 04b3db48..ac9420ea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_08_04_115634) do +ActiveRecord::Schema.define(version: 2019_09_03_162122) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -93,7 +93,7 @@ ActiveRecord::Schema.define(version: 2019_08_04_115634) do t.bigint "account_id" t.string "image_file_name" t.string "image_content_type" - t.bigint "image_file_size" + t.integer "image_file_size" t.datetime "image_updated_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -157,10 +157,10 @@ ActiveRecord::Schema.define(version: 2019_08_04_115634) do t.string "actor_type" t.boolean "discoverable" t.string "also_known_as", array: true - t.datetime "silenced_at" - t.datetime "suspended_at" t.boolean "is_pro", default: false, null: false t.datetime "pro_expires_at" + t.datetime "silenced_at" + t.datetime "suspended_at" t.boolean "is_verified", default: false, null: false t.boolean "is_donor", default: false, null: false t.boolean "is_investor", default: false, null: false @@ -578,6 +578,15 @@ ActiveRecord::Schema.define(version: 2019_08_04_115634) do t.index ["status_id", "preview_card_id"], name: "index_preview_cards_statuses_on_status_id_and_preview_card_id" end + create_table "promotions", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "expires_at" + t.bigint "status_id", null: false + t.string "timeline_id" + t.integer "position", default: 10 + end + create_table "relays", force: :cascade do |t| t.string "inbox_url", default: "", null: false t.string "follow_activity_id" @@ -658,8 +667,8 @@ ActiveRecord::Schema.define(version: 2019_08_04_115634) do create_table "status_pins", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "status_id", null: false - t.datetime "created_at", default: -> { "now()" }, null: false - t.datetime "updated_at", default: -> { "now()" }, null: false + t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, 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 end