diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index d5889501189..00418a13d29 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -96,6 +96,7 @@ export interface ApiStatusJSON { replies_count: number; reblogs_count: number; favorites_count: number; + quotes_count: number; edited_at?: string; favorited?: boolean; diff --git a/app/javascript/mastodon/components/status/reblog_button.tsx b/app/javascript/mastodon/components/status/reblog_button.tsx index 936d5506e70..2ebedf781fc 100644 --- a/app/javascript/mastodon/components/status/reblog_button.tsx +++ b/app/javascript/mastodon/components/status/reblog_button.tsx @@ -160,7 +160,12 @@ export const StatusReblogButton: FC = ({ )} icon='retweet' iconComponent={iconComponent} - counter={counters ? (status.get('reblogs_count') as number) : undefined} + counter={ + counters + ? (status.get('reblogs_count') as number) + + (status.get('quotes_count') as number) + : undefined + } active={isReblogged} /> @@ -283,7 +288,12 @@ export const LegacyReblogButton: FC = ({ icon='retweet' iconComponent={iconComponent} onClick={!disabled ? handleClick : undefined} - counter={counters ? (status.get('reblogs_count') as number) : undefined} + counter={ + counters + ? (status.get('reblogs_count') as number) + + (status.get('quotes_count') as number) + : undefined + } /> ); }; diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx index 24c88f95050..919a41cbae0 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx @@ -233,7 +233,10 @@ export const Footer: React.FC<{ icon='retweet' iconComponent={reblogIconComponent} onClick={handleReblogClick} - counter={status.get('reblogs_count') as number} + counter={ + (status.get('reblogs_count') as number) + + (status.get('quotes_count') as number) + } /> + + + + + + ); + } + const favouriteLink = ( {reblogLink} {reblogLink && <>·} + {quotesLink} + {quotesLink && <>·} {favouriteLink} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 63c96173cd7..0657f9efeba 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -900,6 +900,7 @@ "status.quote_policy_change": "Change who can quote", "status.quote_post_author": "Quoted a post by @{name}", "status.quote_private": "Private posts cannot be quoted", + "status.quotes": "{count, plural, one {quote} other {quotes}}", "status.read_more": "Read more", "status.reblog": "Boost", "status.reblog_private": "Boost with original visibility", diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index c379d55c0de..f86aa772dc8 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -68,6 +68,7 @@ export const statusFactory: FactoryFunction = ({ url: 'https://example.com/status/1', replies_count: 0, reblogs_count: 0, + quotes_count: 0, favorites_count: 0, account: accountFactory(), media_attachments: [], diff --git a/app/models/quote.rb b/app/models/quote.rb index fea812924cc..ad5c9509ea1 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -40,6 +40,10 @@ class Quote < ApplicationRecord validates :approval_uri, absence: true, if: -> { quoted_account&.local? } validate :validate_visibility + after_create_commit :increment_counter_caches! + after_destroy_commit :decrement_counter_caches! + after_update_commit :update_counter_caches! + def accept! update!(state: :accepted) end @@ -84,4 +88,27 @@ class Quote < ApplicationRecord def set_activity_uri self.activity_uri = [ActivityPub::TagManager.instance.uri_for(account), '/quote_requests/', SecureRandom.uuid].join end + + def increment_counter_caches! + return unless acceptable? + + quoted_status&.increment_count!(:quotes_count) + end + + def decrement_counter_caches! + return unless acceptable? + + quoted_status&.decrement_count!(:quotes_count) + end + + def update_counter_caches! + return if legacy? || !state_previously_changed? + + if acceptable? + quoted_status&.increment_count!(:quotes_count) + else + # TODO: are there cases where this would not be correct? + quoted_status&.decrement_count!(:quotes_count) + end + end end diff --git a/app/models/status.rb b/app/models/status.rb index c99a1f8df56..e933c92cae0 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -301,6 +301,10 @@ class Status < ApplicationRecord status_stat&.favourites_count || 0 end + def quotes_count + status_stat&.quotes_count || 0 + end + # Reblogs count received from an external instance def untrusted_reblogs_count status_stat&.untrusted_reblogs_count unless local? diff --git a/app/models/status_stat.rb b/app/models/status_stat.rb index 14a02071a7b..24129cf311b 100644 --- a/app/models/status_stat.rb +++ b/app/models/status_stat.rb @@ -5,14 +5,15 @@ # Table name: status_stats # # id :bigint(8) not null, primary key -# status_id :bigint(8) not null -# replies_count :bigint(8) default(0), not null -# reblogs_count :bigint(8) default(0), not null # favourites_count :bigint(8) default(0), not null -# created_at :datetime not null -# updated_at :datetime not null +# quotes_count :bigint(8) default(0), not null +# reblogs_count :bigint(8) default(0), not null +# replies_count :bigint(8) default(0), not null # untrusted_favourites_count :bigint(8) # untrusted_reblogs_count :bigint(8) +# created_at :datetime not null +# updated_at :datetime not null +# status_id :bigint(8) not null # class StatusStat < ApplicationRecord @@ -34,6 +35,10 @@ class StatusStat < ApplicationRecord [attributes['favourites_count'], 0].max end + def quotes_count + [attributes['quotes_count'], 0].max + end + private def clamp_untrusted_counts diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index c7c58ee14a5..db067149808 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -99,6 +99,10 @@ class REST::StatusSerializer < ActiveModel::Serializer object.untrusted_favourites_count || relationships&.attributes_map&.dig(object.id, :favourites_count) || object.favourites_count end + def quotes_count + relationships&.attributes_map&.dig(object.id, :quotes_count) || object.quotes_count + end + def favourited if relationships relationships.favourites_map[object.id] || false diff --git a/db/migrate/20250820084312_add_quotes_count_to_status_stat.rb b/db/migrate/20250820084312_add_quotes_count_to_status_stat.rb new file mode 100644 index 00000000000..b72274b9ebf --- /dev/null +++ b/db/migrate/20250820084312_add_quotes_count_to_status_stat.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddQuotesCountToStatusStat < ActiveRecord::Migration[8.0] + def change + add_column :status_stats, :quotes_count, :bigint, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index b01ffcf5fb8..81b0fe7ccc1 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[8.0].define(version: 2025_08_19_100545) do +ActiveRecord::Schema[8.0].define(version: 2025_08_20_084312) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1103,6 +1103,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_19_100545) do t.datetime "updated_at", precision: nil, null: false t.bigint "untrusted_favourites_count" t.bigint "untrusted_reblogs_count" + t.bigint "quotes_count", default: 0, null: false t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true end diff --git a/lib/mastodon/cli/cache.rb b/lib/mastodon/cli/cache.rb index cfb6cba1ff2..9f66cd49378 100644 --- a/lib/mastodon/cli/cache.rb +++ b/lib/mastodon/cli/cache.rb @@ -63,6 +63,7 @@ module Mastodon::CLI status_stat.replies_count = status.replies.not_direct_visibility.count status_stat.reblogs_count = status.reblogs.count status_stat.favourites_count = status.favourites.count + status_stat.quotes_count = status.quotes.accepted.count status_stat.save if status_stat.changed? end