Add status references support

This commit is contained in:
KMY 2023-07-06 12:55:11 +09:00
parent af161fa66b
commit 22ad776635
24 changed files with 321 additions and 7 deletions

View file

@ -109,6 +109,7 @@ Metrics/ParameterLists:
Metrics/PerceivedComplexity:
Exclude:
- 'app/policies/status_policy.rb'
- 'app/services/post_status_service.rb'
# Reason: Prevailing style is argument file paths
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath

View file

@ -143,6 +143,7 @@ const excludeTypesFromFilter = filter => {
'favourite',
'emoji_reaction',
'reblog',
'status_reference',
'mention',
'poll',
'status',

View file

@ -152,6 +152,17 @@ export default class ColumnSettings extends PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-status_reference'>
<span id='notifications-status_reference' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status_reference' defaultMessage='References:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status_reference']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status_reference']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status_reference']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status_reference']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-poll'>
<span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>

View file

@ -10,6 +10,7 @@ const tooltips = defineMessages({
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
status_references: { id: 'notifications.filter.status_references', defaultMessage: 'Status references' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
@ -90,6 +91,13 @@ class FilterBar extends PureComponent {
>
<Icon id='retweet' fixedWidth />
</button>
<button
className={selectedFilter === 'status_reference' ? 'active' : ''}
onClick={this.onClick('status_reference')}
title={intl.formatMessage(tooltips.status_references)}
>
<Icon id='retweet' fixedWidth />
</button>
<button
className={selectedFilter === 'poll' ? 'active' : ''}
onClick={this.onClick('poll')}

View file

@ -28,6 +28,7 @@ const messages = defineMessages({
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
statusReference: { id: 'notification.status_reference', defaultMessage: '{name} refered' },
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
@ -288,6 +289,39 @@ class Notification extends ImmutablePureComponent {
);
}
renderStatusReference (notification, link) {
const { intl, unread } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-reblog focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='retweet' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.status_reference' defaultMessage='{name} referenced your status' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions
/>
</div>
</HotKeys>
);
}
renderStatus (notification, link) {
const { intl, unread, status } = this.props;
@ -479,6 +513,8 @@ class Notification extends ImmutablePureComponent {
return this.renderEmojiReaction(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
case 'status_reference':
return this.renderStatusReference(notification, link);
case 'status':
return this.renderStatus(notification, link);
case 'update':

View file

@ -415,6 +415,7 @@
"notification.poll": "アンケートが終了しました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.status": "{name}さんが投稿しました",
"notification.status_reference": "{name}さんがあなたの投稿を参照しました",
"notification.update": "{name}さんが投稿を編集しました",
"notifications.clear": "通知を消去",
"notifications.clear_confirmation": "本当に通知を消去しますか?",

View file

@ -97,6 +97,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
fetch_replies(@status)
distribute
forward_for_reply
process_references!
join_group!
end
@ -471,6 +472,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
retry
end
def process_references!
references = []
references = ActivityPub::FetchReferencesService(@json['references']) unless @json['references'].nil?
ProcessReferencesWorker.perform_async(@status.id, [], urls: references)
end
def join_group!
GroupReblogService.new.call(@status)
end

View file

@ -26,6 +26,7 @@ class Notification < ApplicationRecord
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'EmojiReaction' => :emoji_reaction,
'StatusReference' => :status_reference,
'Poll' => :poll,
}.freeze
@ -33,6 +34,7 @@ class Notification < ApplicationRecord
mention
status
reblog
status_reference
follow
follow_request
favourite
@ -47,6 +49,7 @@ class Notification < ApplicationRecord
TARGET_STATUS_INCLUDES_BY_TYPE = {
status: :status,
reblog: [status: :reblog],
status_reference: [status_reference: :status],
mention: [mention: :status],
favourite: [favourite: :status],
emoji_reaction: [emoji_reaction: :status],
@ -67,6 +70,7 @@ class Notification < ApplicationRecord
belongs_to :follow_request, inverse_of: :notification
belongs_to :favourite, inverse_of: :notification
belongs_to :emoji_reaction, inverse_of: :notification
belongs_to :status_reference, inverse_of: :notification
belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false
end
@ -85,6 +89,8 @@ class Notification < ApplicationRecord
status
when :reblog
status&.reblog
when :status_reference
status_reference&.status
when :favourite
favourite&.status
when :emoji_reaction, :reaction
@ -136,6 +142,8 @@ class Notification < ApplicationRecord
notification.status = cached_status
when :reblog
notification.status.reblog = cached_status
when :status_reference
notification.status_reference.status = cached_status
when :favourite
notification.favourite.status = cached_status
when :emoji_reaction, :reaction
@ -162,7 +170,7 @@ class Notification < ApplicationRecord
case activity_type
when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'EmojiReact', 'FollowRequest', 'Poll', 'Report'
self.from_account_id = activity&.account_id
when 'Mention'
when 'Mention', 'StatusReference'
self.from_account_id = activity&.status&.account_id
when 'Account'
self.from_account_id = activity&.id

View file

@ -75,6 +75,10 @@ class Status < ApplicationRecord
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
has_many :reference_objects, class_name: 'StatusReference', inverse_of: :status
has_many :references, through: :reference_objects, class_name: 'Status', source: :target_status
has_many :referenced_by_status_objects, foreign_key: 'target_status_id', class_name: 'StatusReference', inverse_of: :target_status
has_many :referenced_by_statuses, through: :referenced_by_status_objects, class_name: 'Status', source: :status
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@ -332,6 +336,10 @@ class Status < ApplicationRecord
status_stat&.emoji_reaction_accounts_count || 0
end
def status_referred_by_count
status_stat&.status_referred_by_count || 0
end
def increment_count!(key)
update_status_stat!(key => public_send(key) + 1)
end
@ -340,6 +348,10 @@ class Status < ApplicationRecord
update_status_stat!(key => [public_send(key) - 1, 0].max)
end
def add_status_referred_by_count!(diff)
update_status_stat!(status_referred_by_count: [public_send(:status_referred_by_count) + diff, 0].max)
end
def emoji_reactions_grouped_by_name(account = nil)
(Oj.load(status_stat&.emoji_reactions || '', mode: :strict) || []).tap do |emoji_reactions|
if account.present?

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: status_references
#
# id :bigint(8) not null, primary key
# status_id :bigint(8) not null
# target_status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusReference < ApplicationRecord
belongs_to :status
belongs_to :target_status, class_name: 'Status'
has_one :notification, as: :activity, dependent: :destroy
validate :validate_status_visibilities
def validate_status_visibilities
raise Mastodon::ValidationError, I18n.t('status_references.errors.invalid_status_visibilities') if [:public, :public_unlisted, :unlisted, :login].exclude?(target_status.visibility.to_sym)
end
end

View file

@ -15,6 +15,7 @@
# emoji_reactions_count :integer default(0), not null
# test :integer default(0), not null
# emoji_reaction_accounts_count :integer default(0), not null
# status_referred_by_count :integer default(0), not null
#
class StatusStat < ApplicationRecord
@ -46,6 +47,10 @@ class StatusStat < ApplicationRecord
[attributes['emoji_reaction_accounts_count'], 0].max
end
def status_referred_by_count
[attributes['status_referred_by_count'], 0].max
end
private
def reset_parent_cache

View file

@ -40,6 +40,7 @@
# sign_up_ip :inet
# role_id :bigint(8)
# settings :text
# time_zone :string
#
class User < ApplicationRecord

View file

@ -117,6 +117,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:kmyblue_markdown,
:kmyblue_reaction_deck,
:kmyblue_visibility_login,
:status_reference,
]
capabilities << :profile_search unless Chewy.enabled?

View file

@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :emoji_reaction, :reaction, :reblog, :status, :mention, :poll, :update].include?(object.type)
[:favourite, :emoji_reaction, :reaction, :reblog, :status_reference, :status, :mention, :poll, :update].include?(object.type)
end
def report_type?

View file

@ -6,6 +6,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :visibility_ex, :language,
:uri, :url, :replies_count, :reblogs_count, :searchability, :markdown,
:status_reference_ids, :status_references_count, :status_referred_by_count,
:favourites_count, :emoji_reactions, :emoji_reactions_count, :reactions, :edited_at
attribute :favourited, if: :current_user?
@ -92,6 +93,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
ActivityPub::TagManager.instance.url_for(object)
end
def status_reference_ids
@status_reference_ids = object.reference_objects.pluck(:target_status_id)
end
def status_references_count
status_reference_ids.size
end
def favourited
if instance_options && instance_options[:relationships]
instance_options[:relationships].favourites_map[object.id] || false

View file

@ -126,6 +126,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
:kmyblue_markdown,
:kmyblue_reaction_deck,
:kmyblue_visibility_login,
:status_reference,
]
capabilities << :profile_search unless Chewy.enabled?

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
class ActivityPub::FetchReferencesService < BaseService
include JsonLdHelper
def call(status, collection_or_uri)
@account = status.account
collection_items(collection_or_uri)&.map { |item| value_or_id(item) }
end
private
def collection_items(collection_or_uri)
collection = fetch_collection(collection_or_uri)
return unless collection.is_a?(Hash) && collection['first'].present?
all_items = []
collection = fetch_collection(collection['first'])
while collection.is_a?(Hash)
items = begin
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
end
end
break if items.blank?
all_items.concat(items)
break if all_items.size >= StatusReferenceValidator::LIMIT
collection = collection['next'].present? ? fetch_collection(collection['next']) : nil
end
all_items
end
def fetch_collection(collection_or_uri)
return collection_or_uri if collection_or_uri.is_a?(Hash)
return if invalid_origin?(collection_or_uri)
fetch_resource_without_id_validation(collection_or_uri, nil, true)
end
end

View file

@ -34,6 +34,7 @@ class PostStatusService < BaseService
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
# @option [Enumerable] :allowed_mentions Optional array of expected mentioned account IDs, raises `UnexpectedMentionsError` if unexpected accounts end up in mentions
# @option [Enumerable] :status_reference_ids Optional array
# @return [Status]
def call(account, options = {})
@account = account
@ -78,6 +79,7 @@ class PostStatusService < BaseService
@markdown = @options[:markdown] || false
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
rescue ArgumentError
raise ActiveRecord::RecordInvalid
end
@ -146,6 +148,7 @@ class PostStatusService < BaseService
def postprocess_status!
process_hashtags_service.call(@status)
ProcessReferencesWorker.perform_async(@status.id, @reference_ids)
Trends.tags.register(@status)
LinkCrawlWorker.perform_async(@status.id)
DistributionWorker.perform_async(@status.id)
@ -221,6 +224,7 @@ class PostStatusService < BaseService
media_attachments: @media || [],
ordered_media_attachment_ids: (@options[:media_ids] || []).map(&:to_i) & @media.map(&:id),
thread: @in_reply_to,
status_reference_ids: @status_reference_ids,
poll_attributes: poll_attributes,
sensitive: @sensitive,
spoiler_text: @options[:spoiler_text] || '',

View file

@ -0,0 +1,86 @@
# frozen_string_literal: true
class ProcessReferencesService < BaseService
include Payloadable
DOMAIN = ENV['WEB_DOMAIN'] || ENV.fetch('LOCAL_DOMAIN', nil)
REFURL_EXP = /(RT|QT|BT|RN)((:)? +|:)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/
STATUSID_EXP = %r{(http|https)://#{DOMAIN}/@[a-zA-Z0-9]+/([0-9]{16,})}
def call(status, reference_parameters, save_records: true, urls: nil)
@status = status
@reference_parameters = reference_parameters || []
@save_records = save_records
@urls = urls || []
old_references
StatusReference.transaction do
remove_old_references
add_references
@status.save! if @save_records
end
create_notifications!
end
private
def references
@references = @reference_parameters + scan_text!
end
def old_references
@old_references = @status.references.pluck(:id)
end
def added_references
(references - old_references).uniq
end
def removed_references
(old_references - references).uniq
end
def scan_text!
text = @status.text.gsub(%r{</?[^>]*>}, '')
@scan_text = fetch_statuses!(text.scan(REFURL_EXP).pluck(3).uniq).map(&:id).uniq.filter { |status_id| !status_id.zero? }
end
def fetch_statuses!(urls)
(urls + @urls)
.map { |url| ResolveURLService.new.call(url) }
.filter { |status| status }
end
def add_references
return if added_references.empty?
@added_objects = []
statuses = Status.where(id: added_references)
statuses.each do |status|
@added_objects << @status.reference_objects.new(target_status: status)
status.increment_count!(:status_referred_by_count)
end
end
def create_notifications!
return if @added_objects.empty?
LocalNotificationWorker.push_bulk(@added_objects) do |ref|
[ref.target_status.account_id, ref.id, 'StatusReference', 'status_reference']
end
end
def remove_old_references
return if removed_references.empty?
statuses = Status.where(id: removed_references)
@status.reference_objects.where(target_status: statuses).destroy_all
statuses.each do |status|
status.decrement_count!(:status_referred_by_count)
end
end
end

View file

@ -142,6 +142,7 @@ class UpdateStatusService < BaseService
def update_metadata!
ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status)
ProcessReferencesWorker.perform_async(@status.id, (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?))
end
def broadcast_updates!

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ProcessReferencesWorker
include Sidekiq::Worker
def perform(status_id, ids, urls: nil)
ProcessReferencesService.new.call(Status.find(status_id), ids || [], urls: urls)
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateStatusReferences < ActiveRecord::Migration[6.1]
def change
create_table :status_references do |t|
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
t.belongs_to :target_status, null: false, foreign_key: { on_delete: :cascade, to_table: :statuses }
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class AddStatusReferredByCountToStatusStats < ActiveRecord::Migration[6.1]
def up
safety_assured do
add_column :status_stats, :status_referred_by_count, :integer, null: false, default: 0
end
end
def down
remove_column :status_stats, :status_referred_by_count
end
end

View file

@ -1,5 +1,3 @@
# rubocop:disable all
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
@ -12,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2023_06_05_085710) do
ActiveRecord::Schema.define(version: 2023_07_06_031715) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -346,6 +344,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "dump_file_size"
t.index ["user_id"], name: "index_backups_on_user_id"
end
create_table "blocks", force: :cascade do |t|
@ -804,6 +803,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
t.bigint "owner_id"
t.boolean "confidential", default: true, null: false
t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type"
t.index ["superapp"], name: "index_oauth_applications_on_superapp", where: "(superapp = true)"
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
@ -1041,6 +1041,15 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
t.index ["status_id"], name: "index_status_pins_on_status_id"
end
create_table "status_references", force: :cascade do |t|
t.bigint "status_id", null: false
t.bigint "target_status_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["status_id"], name: "index_status_references_on_status_id"
t.index ["target_status_id"], name: "index_status_references_on_target_status_id"
end
create_table "status_stats", force: :cascade do |t|
t.bigint "status_id", null: false
t.bigint "replies_count", default: 0, null: false
@ -1052,6 +1061,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
t.integer "emoji_reactions_count", default: 0, null: false
t.integer "test", default: 0, null: false
t.integer "emoji_reaction_accounts_count", default: 0, null: false
t.integer "status_referred_by_count", default: 0, null: false
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
end
@ -1211,6 +1221,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
t.boolean "skip_sign_in_token"
t.bigint "role_id"
t.text "settings"
t.string "time_zone"
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)"
@ -1374,6 +1385,8 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
add_foreign_key "status_edits", "statuses", on_delete: :cascade
add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
add_foreign_key "status_pins", "statuses", on_delete: :cascade
add_foreign_key "status_references", "statuses", column: "target_status_id", on_delete: :cascade
add_foreign_key "status_references", "statuses", on_delete: :cascade
add_foreign_key "status_stats", "statuses", on_delete: :cascade
add_foreign_key "status_trends", "accounts", on_delete: :cascade
add_foreign_key "status_trends", "statuses", on_delete: :cascade
@ -1491,5 +1504,3 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
add_index "follow_recommendations", ["account_id"], name: "index_follow_recommendations_on_account_id", unique: true
end
# rubocop:enable all