Add ability to view previous edits of a status in admin UI (#19462)
* Add ability to view previous edits of a status in admin UI * Change moderator access to posts to be controlled by a separate policy
This commit is contained in:
		| @@ -3,18 +3,23 @@ | ||||
| module Admin | ||||
|   class StatusesController < BaseController | ||||
|     before_action :set_account | ||||
|     before_action :set_statuses | ||||
|     before_action :set_statuses, except: :show | ||||
|     before_action :set_status, only: :show | ||||
|  | ||||
|     PER_PAGE = 20 | ||||
|  | ||||
|     def index | ||||
|       authorize :status, :index? | ||||
|       authorize [:admin, :status], :index? | ||||
|  | ||||
|       @status_batch_action = Admin::StatusBatchAction.new | ||||
|     end | ||||
|  | ||||
|     def show | ||||
|       authorize [:admin, @status], :show? | ||||
|     end | ||||
|  | ||||
|     def batch | ||||
|       authorize :status, :index? | ||||
|       authorize [:admin, :status], :index? | ||||
|  | ||||
|       @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button)) | ||||
|       @status_batch_action.save! | ||||
| @@ -32,6 +37,7 @@ module Admin | ||||
|  | ||||
|     def after_create_redirect_path | ||||
|       report_id = @status_batch_action&.report_id || params[:report_id] | ||||
|  | ||||
|       if report_id.present? | ||||
|         admin_report_path(report_id) | ||||
|       else | ||||
| @@ -43,6 +49,10 @@ module Admin | ||||
|       @account = Account.find(params[:account_id]) | ||||
|     end | ||||
|  | ||||
|     def set_status | ||||
|       @status = @account.statuses.find(params[:id]) | ||||
|     end | ||||
|  | ||||
|     def set_statuses | ||||
|       @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE) | ||||
|     end | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| class Admin::Trends::StatusesController < Admin::BaseController | ||||
|   def index | ||||
|     authorize :status, :review? | ||||
|     authorize [:admin, :status], :review? | ||||
|  | ||||
|     @locales  = StatusTrend.pluck('distinct language') | ||||
|     @statuses = filtered_statuses.page(params[:page]) | ||||
| @@ -10,7 +10,7 @@ class Admin::Trends::StatusesController < Admin::BaseController | ||||
|   end | ||||
|  | ||||
|   def batch | ||||
|     authorize :status, :review? | ||||
|     authorize [:admin, :status], :review? | ||||
|  | ||||
|     @form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button)) | ||||
|     @form.save | ||||
|   | ||||
| @@ -323,7 +323,7 @@ class StatusActionBar extends ImmutablePureComponent { | ||||
|       if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { | ||||
|         menu.push(null); | ||||
|         menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); | ||||
|         menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); | ||||
|         menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -254,7 +254,7 @@ class ActionBar extends React.PureComponent { | ||||
|       if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { | ||||
|         menu.push(null); | ||||
|         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?id=${status.get('id')}` }); | ||||
|         menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1752,3 +1752,67 @@ a.sparkline { | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .history { | ||||
|   counter-reset: step 0; | ||||
|   font-size: 15px; | ||||
|   line-height: 22px; | ||||
|  | ||||
|   li { | ||||
|     counter-increment: step 1; | ||||
|     padding-left: 2.5rem; | ||||
|     padding-bottom: 8px; | ||||
|     position: relative; | ||||
|     margin-bottom: 8px; | ||||
|  | ||||
|     &::before { | ||||
|       position: absolute; | ||||
|       content: counter(step); | ||||
|       font-size: 0.625rem; | ||||
|       font-weight: 500; | ||||
|       left: 0; | ||||
|       display: flex; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       width: calc(1.375rem + 1px); | ||||
|       height: calc(1.375rem + 1px); | ||||
|       background: $ui-base-color; | ||||
|       border: 1px solid $highlight-text-color; | ||||
|       color: $highlight-text-color; | ||||
|       border-radius: 8px; | ||||
|     } | ||||
|  | ||||
|     &::after { | ||||
|       position: absolute; | ||||
|       content: ""; | ||||
|       width: 1px; | ||||
|       background: $highlight-text-color; | ||||
|       bottom: 0; | ||||
|       top: calc(1.875rem + 1px); | ||||
|       left: 0.6875rem; | ||||
|     } | ||||
|  | ||||
|     &:last-child { | ||||
|       margin-bottom: 0; | ||||
|  | ||||
|       &::after { | ||||
|         display: none; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__entry { | ||||
|     h5 { | ||||
|       font-weight: 500; | ||||
|       color: $primary-text-color; | ||||
|       line-height: 25px; | ||||
|       margin-bottom: 16px; | ||||
|     } | ||||
|  | ||||
|     .status { | ||||
|       border: 1px solid lighten($ui-base-color, 4%); | ||||
|       background: $ui-base-color; | ||||
|       border-radius: 4px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,6 @@ | ||||
| class Admin::StatusFilter | ||||
|   KEYS = %i( | ||||
|     media | ||||
|     id | ||||
|     report_id | ||||
|   ).freeze | ||||
|  | ||||
| @@ -28,12 +27,10 @@ class Admin::StatusFilter | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def scope_for(key, value) | ||||
|   def scope_for(key, _value) | ||||
|     case key.to_s | ||||
|     when 'media' | ||||
|       Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id).reorder('statuses.id desc') | ||||
|     when 'id' | ||||
|       Status.where(id: value) | ||||
|     else | ||||
|       raise "Unknown filter: #{key}" | ||||
|     end | ||||
|   | ||||
| @@ -30,7 +30,7 @@ class StatusEdit < ApplicationRecord | ||||
|              :preview_remote_url, :text_url, :meta, :blurhash, | ||||
|              :not_processed?, :needs_redownload?, :local?, | ||||
|              :file, :thumbnail, :thumbnail_remote_url, | ||||
|              :shortcode, to: :media_attachment | ||||
|              :shortcode, :video?, :audio?, to: :media_attachment | ||||
|   end | ||||
|  | ||||
|   rate_limit by: :account, family: :statuses | ||||
| @@ -40,7 +40,8 @@ class StatusEdit < ApplicationRecord | ||||
|  | ||||
|   default_scope { order(id: :asc) } | ||||
|  | ||||
|   delegate :local?, to: :status | ||||
|   delegate :local?, :application, :edited?, :edited_at, | ||||
|            :discarded?, :visibility, to: :status | ||||
|  | ||||
|   def emojis | ||||
|     return @emojis if defined?(@emojis) | ||||
| @@ -59,4 +60,12 @@ class StatusEdit < ApplicationRecord | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def proper | ||||
|     self | ||||
|   end | ||||
|  | ||||
|   def reblog? | ||||
|     false | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										29
									
								
								app/policies/admin/status_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/policies/admin/status_policy.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Admin::StatusPolicy < ApplicationPolicy | ||||
|   def initialize(current_account, record, preloaded_relations = {}) | ||||
|     super(current_account, record) | ||||
|  | ||||
|     @preloaded_relations = preloaded_relations | ||||
|   end | ||||
|  | ||||
|   def index? | ||||
|     role.can?(:manage_reports, :manage_users) | ||||
|   end | ||||
|  | ||||
|   def show? | ||||
|     role.can?(:manage_reports, :manage_users) && (record.public_visibility? || record.unlisted_visibility? || record.reported?) | ||||
|   end | ||||
|  | ||||
|   def destroy? | ||||
|     role.can?(:manage_reports) | ||||
|   end | ||||
|  | ||||
|   def update? | ||||
|     role.can?(:manage_reports) | ||||
|   end | ||||
|  | ||||
|   def review? | ||||
|     role.can?(:manage_taxonomies) | ||||
|   end | ||||
| end | ||||
| @@ -7,10 +7,6 @@ class StatusPolicy < ApplicationPolicy | ||||
|     @preloaded_relations = preloaded_relations | ||||
|   end | ||||
|  | ||||
|   def index? | ||||
|     role.can?(:manage_reports, :manage_users) | ||||
|   end | ||||
|  | ||||
|   def show? | ||||
|     return false if author.suspended? | ||||
|  | ||||
| @@ -32,17 +28,13 @@ class StatusPolicy < ApplicationPolicy | ||||
|   end | ||||
|  | ||||
|   def destroy? | ||||
|     role.can?(:manage_reports) || owned? | ||||
|     owned? | ||||
|   end | ||||
|  | ||||
|   alias unreblog? destroy? | ||||
|  | ||||
|   def update? | ||||
|     role.can?(:manage_reports) || owned? | ||||
|   end | ||||
|  | ||||
|   def review? | ||||
|     role.can?(:manage_taxonomies) | ||||
|     owned? | ||||
|   end | ||||
|  | ||||
|   private | ||||
|   | ||||
							
								
								
									
										8
									
								
								app/views/admin/reports/_media_attachments.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								app/views/admin/reports/_media_attachments.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| - if status.ordered_media_attachments.first.video? | ||||
|   - video = status.ordered_media_attachments.first | ||||
|   = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: status.sensitive?, visible: false, width: 610, height: 343, inline: true, alt: video.description, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json | ||||
| - elsif status.ordered_media_attachments.first.audio? | ||||
|   - audio = status.ordered_media_attachments.first | ||||
|   = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) | ||||
| - else | ||||
|   = react_component :media_gallery, height: 343, sensitive: status.sensitive?, visible: false, media: status.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } | ||||
| @@ -12,14 +12,7 @@ | ||||
|           = prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis) | ||||
|  | ||||
|     - unless status.proper.ordered_media_attachments.empty? | ||||
|       - if status.proper.ordered_media_attachments.first.video? | ||||
|         - video = status.proper.ordered_media_attachments.first | ||||
|         = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: status.proper.sensitive?, visible: false, width: 610, height: 343, inline: true, alt: video.description, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json | ||||
|       - elsif status.proper.ordered_media_attachments.first.audio? | ||||
|         - audio = status.proper.ordered_media_attachments.first | ||||
|         = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) | ||||
|       - else | ||||
|         = react_component :media_gallery, height: 343, sensitive: status.proper.sensitive?, visible: false, media: status.proper.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } | ||||
|       = render partial: 'admin/reports/media_attachments', locals: { status: status.proper } | ||||
|  | ||||
|     .detailed-status__meta | ||||
|       - if status.application | ||||
| @@ -29,7 +22,7 @@ | ||||
|         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) | ||||
|       - if status.edited? | ||||
|         · | ||||
|         = t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted')) | ||||
|         = link_to t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted')), admin_account_status_path(status.account_id, status), class: 'detailed-status__datetime' | ||||
|       - if status.discarded? | ||||
|         · | ||||
|         %span.negative-hint= t('admin.statuses.deleted') | ||||
|   | ||||
							
								
								
									
										20
									
								
								app/views/admin/status_edits/_status_edit.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/views/admin/status_edits/_status_edit.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| .status | ||||
|   .status__content>< | ||||
|     - if status_edit.spoiler_text.blank? | ||||
|       = prerender_custom_emojis(status_content_format(status_edit), status_edit.emojis) | ||||
|     - else | ||||
|       %details< | ||||
|         %summary>< | ||||
|           %strong> Content warning: #{prerender_custom_emojis(h(status_edit.spoiler_text), status_edit.emojis)} | ||||
|         = prerender_custom_emojis(status_content_format(status_edit), status_edit.emojis) | ||||
|  | ||||
|   - unless status_edit.ordered_media_attachments.empty? | ||||
|     = render partial: 'admin/reports/media_attachments', locals: { status: status_edit } | ||||
|  | ||||
|   .detailed-status__meta | ||||
|     %time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at) | ||||
|  | ||||
|     - if status_edit.sensitive? | ||||
|       · | ||||
|       = fa_icon('eye-slash fw') | ||||
|       = t('stream_entries.sensitive_content') | ||||
							
								
								
									
										64
									
								
								app/views/admin/statuses/show.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								app/views/admin/statuses/show.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| - content_for :header_tags do | ||||
|   = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' | ||||
|  | ||||
| - content_for :page_title do | ||||
|   = t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false)) | ||||
|  | ||||
| - content_for :heading_actions do | ||||
|   = link_to t('admin.statuses.open'), ActivityPub::TagManager.instance.url_for(@status), class: 'button', target: '_blank' | ||||
|  | ||||
| %h3= t('admin.statuses.metadata') | ||||
|  | ||||
| .table-wrapper | ||||
|   %table.table.horizontal-table | ||||
|     %tbody | ||||
|       %tr | ||||
|         %th= t('admin.statuses.account') | ||||
|         %td= admin_account_link_to @status.account | ||||
|       - if @status.reply? | ||||
|         %tr | ||||
|           %th= t('admin.statuses.in_reply_to') | ||||
|           %td= admin_account_link_to @status.in_reply_to_account, path: admin_account_status_path(@status.thread.account_id, @status.in_reply_to_id) | ||||
|       %tr | ||||
|         %th= t('admin.statuses.application') | ||||
|         %td= @status.application&.name | ||||
|       %tr | ||||
|         %th= t('admin.statuses.language') | ||||
|         %td= standard_locale_name(@status.language) | ||||
|       %tr | ||||
|         %th= t('admin.statuses.visibility') | ||||
|         %td= t("statuses.visibilities.#{@status.visibility}") | ||||
|       - if @status.trend | ||||
|         %tr | ||||
|           %th= t('admin.statuses.trending') | ||||
|           %td | ||||
|             - if @status.trend.allowed? | ||||
|               %abbr{ title: t('admin.trends.tags.current_score', score: @status.trend.score) }= t('admin.trends.tags.trending_rank', rank: @status.trend.rank) | ||||
|             - elsif @status.trend.requires_review? | ||||
|               = t('admin.trends.pending_review') | ||||
|             - else | ||||
|               = t('admin.trends.not_allowed_to_trend') | ||||
|       %tr | ||||
|         %th= t('admin.statuses.reblogs') | ||||
|         %td= friendly_number_to_human @status.reblogs_count | ||||
|       %tr | ||||
|         %th= t('admin.statuses.favourites') | ||||
|         %td= friendly_number_to_human @status.favourites_count | ||||
|  | ||||
| %hr.spacer/ | ||||
|  | ||||
| %h3= t('admin.statuses.history') | ||||
|  | ||||
| %ol.history | ||||
|   - @status.edits.includes(:account, status: [:account]).each.with_index do |status_edit, i| | ||||
|     %li | ||||
|       .history__entry | ||||
|         %h5 | ||||
|           - if i.zero? | ||||
|             = t('admin.statuses.original_status') | ||||
|           - else | ||||
|             = t('admin.statuses.status_changed') | ||||
|           · | ||||
|           %time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at) | ||||
|  | ||||
|         = render status_edit | ||||
| @@ -705,16 +705,29 @@ en: | ||||
|       delete: Delete uploaded file | ||||
|       destroyed_msg: Site upload successfully deleted! | ||||
|     statuses: | ||||
|       account: Author | ||||
|       application: Application | ||||
|       back_to_account: Back to account page | ||||
|       back_to_report: Back to report page | ||||
|       batch: | ||||
|         remove_from_report: Remove from report | ||||
|         report: Report | ||||
|       deleted: Deleted | ||||
|       favourites: Favourites | ||||
|       history: Version history | ||||
|       in_reply_to: Replying to | ||||
|       language: Language | ||||
|       media: | ||||
|         title: Media | ||||
|       metadata: Metadata | ||||
|       no_status_selected: No posts were changed as none were selected | ||||
|       open: Open post | ||||
|       original_status: Original post | ||||
|       reblogs: Reblogs | ||||
|       status_changed: Post changed | ||||
|       title: Account posts | ||||
|       trending: Trending | ||||
|       visibility: Visibility | ||||
|       with_media: With media | ||||
|     strikes: | ||||
|       actions: | ||||
|   | ||||
| @@ -325,7 +325,7 @@ Rails.application.routes.draw do | ||||
|       resource :reset, only: [:create] | ||||
|       resource :action, only: [:new, :create], controller: 'account_actions' | ||||
|  | ||||
|       resources :statuses, only: [:index] do | ||||
|       resources :statuses, only: [:index, :show] do | ||||
|         collection do | ||||
|           post :batch | ||||
|         end | ||||
|   | ||||
| @@ -96,10 +96,6 @@ RSpec.describe StatusPolicy, type: :model do | ||||
|       expect(subject).to permit(status.account, status) | ||||
|     end | ||||
|  | ||||
|     it 'grants access when account is admin' do | ||||
|       expect(subject).to permit(admin.account, status) | ||||
|     end | ||||
|  | ||||
|     it 'denies access when account is not deleter' do | ||||
|       expect(subject).to_not permit(bob, status) | ||||
|     end | ||||
| @@ -125,27 +121,9 @@ RSpec.describe StatusPolicy, type: :model do | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   permissions :index? do | ||||
|     it 'grants access if staff' do | ||||
|       expect(subject).to permit(admin.account) | ||||
|     end | ||||
|  | ||||
|     it 'denies access unless staff' do | ||||
|       expect(subject).to_not permit(alice) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   permissions :update? do | ||||
|     it 'grants access if staff' do | ||||
|       expect(subject).to permit(admin.account, status) | ||||
|     end | ||||
|  | ||||
|     it 'grants access if owner' do | ||||
|       expect(subject).to permit(status.account, status) | ||||
|     end | ||||
|  | ||||
|     it 'denies access unless staff' do | ||||
|       expect(subject).to_not permit(bob, status) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
		Reference in New Issue
	
	Block a user