field :id, type: 'long'
field :account_id, type: 'long'
- field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
+ field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.ordered_media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content'
end
end
def render_video_component(status, **options)
- video = status.media_attachments.first
+ video = status.ordered_media_attachments.first
meta = video.file.meta || {}
}.merge(**options)
react_component :video, component_params do
- render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end
end
def render_audio_component(status, **options)
- audio = status.media_attachments.first
+ audio = status.ordered_media_attachments.first
meta = audio.file.meta || {}
}.merge(**options)
react_component :audio, component_params do
- render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end
end
component_params = {
sensitive: sensitized?(status, current_account),
autoplay: prefers_autoplay?,
- media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json },
+ media: status.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json },
}.merge(**options)
react_component :media_gallery, component_params do
- render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end
end
Formatter.instance.plaintext(status),
status.spoiler_text,
status.preloadable_poll ? status.preloadable_poll.options.join("\n\n") : nil,
- status.media_attachments.map(&:description).join("\n\n"),
+ status.ordered_media_attachments.map(&:description).join("\n\n"),
].compact.join("\n\n")
combined_regex.match?(combined_text)
.pub_date(status.created_at)
.description(status.spoiler_text.presence || Formatter.instance.format(status, inline_poll_options: true).to_str)
- status.media_attachments.each do |media|
+ status.ordered_media_attachments.each do |media|
item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size)
end
end
Status.with_discarded.where(id: status_ids)
end
- def media_attachments
- MediaAttachment.where(status_id: status_ids)
+ def media_attachments_count
+ statuses_to_query = []
+ count = 0
+
+ statuses.pluck(:id, :ordered_media_attachment_ids).each do |id, ordered_ids|
+ if ordered_ids.nil?
+ statuses_to_query << id
+ else
+ count += ordered_ids.size
+ end
+ end
+
+ count += MediaAttachment.where(status_id: statuses_to_query).count unless statuses_to_query.empty?
+ count
end
def rules
#
# Table name: statuses
#
-# id :bigint(8) not null, primary key
-# uri :string
-# text :text default(""), not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# in_reply_to_id :bigint(8)
-# reblog_of_id :bigint(8)
-# url :string
-# sensitive :boolean default(FALSE), not null
-# visibility :integer default("public"), not null
-# spoiler_text :text default(""), not null
-# reply :boolean default(FALSE), not null
-# language :string
-# conversation_id :bigint(8)
-# local :boolean
-# account_id :bigint(8) not null
-# application_id :bigint(8)
-# in_reply_to_account_id :bigint(8)
-# poll_id :bigint(8)
-# deleted_at :datetime
-# edited_at :datetime
-# trendable :boolean
+# id :bigint(8) not null, primary key
+# uri :string
+# text :text default(""), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# in_reply_to_id :bigint(8)
+# reblog_of_id :bigint(8)
+# url :string
+# sensitive :boolean default(FALSE), not null
+# visibility :integer default("public"), not null
+# spoiler_text :text default(""), not null
+# reply :boolean default(FALSE), not null
+# language :string
+# conversation_id :bigint(8)
+# local :boolean
+# account_id :bigint(8) not null
+# application_id :bigint(8)
+# in_reply_to_account_id :bigint(8)
+# poll_id :bigint(8)
+# deleted_at :datetime
+# edited_at :datetime
+# trendable :boolean
+# ordered_media_attachment_ids :bigint(8) is an Array
#
class Status < ApplicationRecord
public_visibility? || unlisted_visibility?
end
- def snapshot!(media_attachments_changed: false, account_id: nil, at_time: nil)
+ def snapshot!(account_id: nil, at_time: nil)
edits.create!(
text: text,
spoiler_text: spoiler_text,
- media_attachments_changed: media_attachments_changed,
+ sensitive: sensitive,
+ ordered_media_attachment_ids: ordered_media_attachment_ids || media_attachments.pluck(:id),
+ media_descriptions: ordered_media_attachments.map(&:description),
+ poll_options: preloadable_poll&.options,
account_id: account_id || self.account_id,
created_at: at_time || edited_at
)
alias sign? distributable?
def with_media?
- media_attachments.any?
+ ordered_media_attachments.any?
end
def with_preview_card?
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
end
+ def ordered_media_attachments
+ if ordered_media_attachment_ids.nil?
+ media_attachments
+ else
+ map = media_attachments.index_by(&:id)
+ ordered_media_attachment_ids.map { |media_attachment_id| map[media_attachment_id] }
+ end
+ end
+
def replies_count
status_stat&.replies_count || 0
end
#
# Table name: status_edits
#
-# id :bigint(8) not null, primary key
-# status_id :bigint(8) not null
-# account_id :bigint(8)
-# text :text default(""), not null
-# spoiler_text :text default(""), not null
-# media_attachments_changed :boolean default(FALSE), not null
-# created_at :datetime not null
-# updated_at :datetime not null
+# id :bigint(8) not null, primary key
+# status_id :bigint(8) not null
+# account_id :bigint(8)
+# text :text default(""), not null
+# spoiler_text :text default(""), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# ordered_media_attachment_ids :bigint(8) is an Array
+# media_descriptions :text is an Array
+# poll_options :string is an Array
+# sensitive :boolean
#
class StatusEdit < ApplicationRecord
+ self.ignored_columns = %w(
+ media_attachments_changed
+ )
+
+ class PreservedMediaAttachment < ActiveModelSerializers::Model
+ attributes :media_attachment, :description
+ delegate :id, :type, :url, :preview_url, :remote_url, :preview_remote_url, :text_url, :meta, :blurhash, to: :media_attachment
+ end
+
belongs_to :status
belongs_to :account, optional: true
return @emojis if defined?(@emojis)
@emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain)
end
+
+ def ordered_media_attachments
+ return @ordered_media_attachments if defined?(@ordered_media_attachments)
+
+ @ordered_media_attachments = begin
+ if ordered_media_attachment_ids.nil?
+ []
+ else
+ map = status.media_attachments.index_by(&:id)
+ ordered_media_attachment_ids.map.with_index { |media_attachment_id, index| PreservedMediaAttachment.new(media_attachment: map[media_attachment_id], description: media_descriptions[index]) }
+ end
+ end
+ end
end
attribute :content_map, if: :language?
attribute :updated, if: :edited?
- has_many :media_attachments, key: :attachment
+ has_many :virtual_attachments, key: :attachment
has_many :virtual_tags, key: :tag
has_one :replies, serializer: ActivityPub::CollectionSerializer, if: :local?
object.account.sensitized? || object.sensitive
end
+ def virtual_attachments
+ object.ordered_media_attachments
+ end
+
def virtual_tags
object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis
end
class REST::StatusEditSerializer < ActiveModel::Serializer
has_one :account, serializer: REST::AccountSerializer
- attributes :content, :spoiler_text,
- :media_attachments_changed, :created_at
+ attributes :content, :spoiler_text, :sensitive, :created_at
+ has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :emojis, serializer: REST::CustomEmojiSerializer
+ attribute :poll, if: -> { object.poll_options.present? }
+
def content
Formatter.instance.format(object)
end
+
+ def poll
+ { options: object.poll_options.map { |title| { title: title } } }
+ end
end
belongs_to :application, if: :show_application?
belongs_to :account, serializer: REST::AccountSerializer
- has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
+ has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :ordered_mentions, key: :mentions
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
end
end
- removed_media_attachments = previous_media_attachments - next_media_attachments
- added_media_attachments = next_media_attachments - previous_media_attachments
+ added_media_attachments = next_media_attachments - previous_media_attachments
- MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
- @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any?
+ @status.ordered_media_attachment_ids = next_media_attachments.map(&:id)
+ @status.media_attachments.reload
+
+ @media_attachments_changed = true if @status.ordered_media_attachment_ids_changed?
end
def update_poll!
return if @status.edits.any?
- @status.snapshot!(
- media_attachments_changed: false,
- at_time: @status.created_at
- )
+ @status.snapshot!(at_time: @status.created_at)
end
def create_edit!
return unless significant_changes?
- @status.snapshot!(
- media_attachments_changed: @media_attachments_changed || @poll_changed,
- account_id: @account.id
- )
+ @status.snapshot!(account_id: @account.id)
end
def skip_download?
Redis.current.publish('timeline:public', anonymous_payload)
Redis.current.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', anonymous_payload)
- if @status.media_attachments.any?
+ if @status.with_media?
Redis.current.publish('timeline:public:media', anonymous_payload)
Redis.current.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', anonymous_payload)
end
end
def validate_media!
- return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
+ if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
+ @media = []
+ return
+ end
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present?
{
text: @text,
media_attachments: @media || [],
+ ordered_media_attachment_ids: (@options[:media_ids] || []).map(&:to_i) & @media.map(&:id),
thread: @in_reply_to,
poll_attributes: poll_attributes,
sensitive: @sensitive,
remove_reblogs
remove_from_hashtags
remove_from_public
- remove_from_media if @status.media_attachments.any?
+ remove_from_media if @status.with_media?
remove_media
end
@status = status
@options = options
@account_id = account_id
- @media_attachments_changed = false
- @poll_changed = false
Status.transaction do
create_previous_edit!
def update_media_attachments!
previous_media_attachments = @status.media_attachments.to_a
next_media_attachments = validate_media!
- removed_media_attachments = previous_media_attachments - next_media_attachments
added_media_attachments = next_media_attachments - previous_media_attachments
- MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
+ @status.ordered_media_attachment_ids = (@options[:media_ids] || []).map(&:to_i) & next_media_attachments.map(&:id)
@status.media_attachments.reload
- @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any?
end
def validate_media!
# If for some reasons the options were changed, it invalidates all previous
# votes, so we need to remove them
- @poll_changed = true if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple
+ poll_changed = true if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple
poll.options = @options[:poll][:options]
poll.hide_totals = @options[:poll][:hide_totals] || false
poll.multiple = @options[:poll][:multiple] || false
poll.expires_in = @options[:poll][:expires_in]
- poll.reset_votes! if @poll_changed
+ poll.reset_votes! if poll_changed
poll.save!
@status.poll_id = poll.id
elsif previous_poll.present?
previous_poll.destroy
- @poll_changed = true
@status.poll_id = nil
end
end
return if @status.edits.any?
- @status.snapshot!(
- media_attachments_changed: false,
- at_time: @status.created_at
- )
+ @status.snapshot!(at_time: @status.created_at)
end
def create_edit!
- @status.snapshot!(
- media_attachments_changed: @media_attachments_changed || @poll_changed,
- account_id: @account_id
- )
+ @status.snapshot!(account_id: @account_id)
end
end
%strong> Content warning: #{Formatter.instance.format_spoiler(status.proper)}
= Formatter.instance.format(status.proper, custom_emojify: true)
- - unless status.proper.media_attachments.empty?
- - if status.proper.media_attachments.first.video?
- - video = status.proper.media_attachments.first
+ - 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.media_attachments.first.audio?
- - audio = status.proper.media_attachments.first
+ - 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.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
+ = 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 }
.detailed-status__meta
- if status.application
%span.report-card__summary__item__content__icon{ title: t('admin.accounts.statuses') }
= fa_icon('comment')
- = report.statuses.count
+ = report.status_ids.size
%span.report-card__summary__item__content__icon{ title: t('admin.accounts.media_attachments') }
= fa_icon('camera')
- = report.media_attachments.count
+ = report.media_attachments_count
- if report.forwarded?
ยท
= link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank', class: 'emojify', rel: 'noopener noreferrer' do
= one_line_preview(status)
- - status.media_attachments.each do |media_attachment|
+ - status.ordered_media_attachments.each do |media_attachment|
%abbr{ title: media_attachment.description }
= fa_icon 'link'
= media_attachment.file_file_name
= link_to short_account_status_url(@strike.target_account, status_id), class: 'emojify' do
= one_line_preview(status)
- - status.media_attachments.each do |media_attachment|
+ - status.ordered_media_attachments.each do |media_attachment|
%abbr{ title: media_attachment.description }
= fa_icon 'link'
= media_attachment.file_file_name
%div.auto-dir
= Formatter.instance.format(status)
- - if status.media_attachments.size > 0
+ - if status.ordered_media_attachments.size > 0
%p
- - status.media_attachments.each do |a|
+ - status.ordered_media_attachments.each do |a|
- if status.local?
= link_to full_asset_url(a.file.url(:original)), full_asset_url(a.file.url(:original))
- else
- if status.preloadable_poll
= render_poll_component(status)
- - if !status.media_attachments.empty?
- - if status.media_attachments.first.video?
+ - if !status.ordered_media_attachments.empty?
+ - if status.ordered_media_attachments.first.video?
= render_video_component(status, width: 670, height: 380, detailed: true)
- - elsif status.media_attachments.first.audio?
+ - elsif status.ordered_media_attachments.first.audio?
= render_audio_component(status, width: 670, height: 380)
- else
= render_media_gallery_component(status, height: 380, standalone: true)
- if activity.is_a?(Status) && (activity.non_sensitive_with_media? || (activity.with_media? && Setting.preview_sensitive_media))
- player_card = false
- - activity.media_attachments.each do |media|
+ - activity.ordered_media_attachments.each do |media|
- if media.image?
= opengraph 'og:image', full_asset_url(media.file.url(:original))
= opengraph 'og:image:type', media.file_content_type
- if status.preloadable_poll
= render_poll_component(status)
- - if !status.media_attachments.empty?
- - if status.media_attachments.first.video?
+ - if !status.ordered_media_attachments.empty?
+ - if status.ordered_media_attachments.first.video?
= render_video_component(status, width: 610, height: 343)
- - elsif status.media_attachments.first.audio?
+ - elsif status.ordered_media_attachments.first.audio?
= render_audio_component(status, width: 610, height: 343)
- else
= render_media_gallery_component(status, height: 343)
--- /dev/null
+class AddOrderedMediaAttachmentIdsToStatuses < ActiveRecord::Migration[6.1]
+ def change
+ add_column :statuses, :ordered_media_attachment_ids, :bigint, array: true
+ end
+end
--- /dev/null
+class AddOrderedMediaAttachmentIdsToStatusEdits < ActiveRecord::Migration[6.1]
+ def change
+ add_column :status_edits, :ordered_media_attachment_ids, :bigint, array: true
+ add_column :status_edits, :media_descriptions, :text, array: true
+ add_column :status_edits, :poll_options, :string, array: true
+ add_column :status_edits, :sensitive, :boolean
+ end
+end
--- /dev/null
+# frozen_string_literal: true
+
+class RemoveMediaAttachmentsChangedFromStatusEdits < ActiveRecord::Migration[5.2]
+ def change
+ safety_assured { remove_column :status_edits, :media_attachments_changed, :boolean, default: false, null: false }
+ end
+end
t.bigint "parent_id"
t.inet "ips", array: true
t.datetime "last_refresh_at"
-
t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
end
t.bigint "account_id"
t.text "text", default: "", null: false
t.text "spoiler_text", default: "", null: false
- t.boolean "media_attachments_changed", default: false, null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
+ t.bigint "ordered_media_attachment_ids", array: true
+ t.text "media_descriptions", array: true
+ t.string "poll_options", array: true
+ t.boolean "sensitive"
t.index ["account_id"], name: "index_status_edits_on_account_id"
t.index ["status_id"], name: "index_status_edits_on_status_id"
end
t.datetime "deleted_at"
t.datetime "edited_at"
t.boolean "trendable"
+ t.bigint "ordered_media_attachment_ids", array: true
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
end
end
- describe 'media_attachments' do
- it 'returns media attachments from statuses' do
- status = Fabricate(:status)
- media_attachment = Fabricate(:media_attachment, status: status)
- _other_media_attachment = Fabricate(:media_attachment)
- report = Fabricate(:report, status_ids: [status.id])
+ describe 'media_attachments_count' do
+ it 'returns count of media attachments in statuses' do
+ status1 = Fabricate(:status, ordered_media_attachment_ids: [1, 2])
+ status2 = Fabricate(:status, ordered_media_attachment_ids: [5])
+ report = Fabricate(:report, status_ids: [status1.id, status2.id])
- expect(report.media_attachments).to eq [media_attachment]
+ expect(report.media_attachments_count).to eq 3
end
end
end
it 'updates media attachments' do
- media_attachment = status.media_attachments.reload.first
+ media_attachment = status.reload.ordered_media_attachments.first
expect(media_attachment).to_not be_nil
expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
end
it 'records media change in edit' do
- expect(status.edits.reload.last.media_attachments_changed).to be true
+ expect(status.edits.reload.last.ordered_media_attachment_ids).to_not be_empty
end
end
end
it 'updates media attachments' do
- expect(status.media_attachments.reload.map(&:remote_url)).to eq %w(https://example.com/foo.png)
+ expect(status.ordered_media_attachments.map(&:remote_url)).to eq %w(https://example.com/foo.png)
end
it 'records media change in edit' do
- expect(status.edits.reload.last.media_attachments_changed).to be true
+ expect(status.edits.reload.last.ordered_media_attachment_ids).to_not be_empty
end
end
end
it 'records media change in edit' do
- expect(status.edits.reload.last.media_attachments_changed).to be true
+ expect(status.edits.reload.last.poll_options).to be_nil
end
end
end
it 'records media change in edit' do
- expect(status.edits.reload.last.media_attachments_changed).to be true
+ expect(status.edits.reload.last.poll_options).to eq %w(Foo Bar Baz)
end
end
subject.call(status, json)
expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC'
end
-
- it 'records that no media has been changed in edit' do
- subject.call(status, json)
- expect(status.edits.reload.last.media_attachments_changed).to be false
- end
end
end
end
it 'saves edit history' do
- expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Bar', false]]
+ expect(status.edits.pluck(:text)).to eq %w(Foo Bar)
end
end
end
it 'saves edit history' do
- expect(status.edits.pluck(:text, :spoiler_text, :media_attachments_changed)).to eq [['Foo', '', false], ['Foo', 'Bar', false]]
+ expect(status.edits.pluck(:text, :spoiler_text)).to eq [['Foo', ''], ['Foo', 'Bar']]
end
end
end
it 'updates media attachments' do
- expect(status.media_attachments.to_a).to eq [attached_media_attachment]
+ expect(status.ordered_media_attachments).to eq [attached_media_attachment]
end
- it 'detaches detached media attachments' do
- expect(detached_media_attachment.reload.status_id).to be_nil
+ it 'does not detach detached media attachments' do
+ expect(detached_media_attachment.reload.status_id).to eq status.id
end
it 'attaches attached media attachments' do
end
it 'saves edit history' do
- expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
+ expect(status.edits.pluck(:ordered_media_attachment_ids)).to eq [[detached_media_attachment.id], [attached_media_attachment.id]]
end
end
end
it 'saves edit history' do
- expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
+ expect(status.edits.pluck(:poll_options)).to eq [%w(Foo Bar), %w(Bar Baz Foo)]
end
end