Add: #8 サークル投稿の転送 (#294)

* Add: `conversations`テーブルに`ancestor_status`プロパティ

* Fix test

* Fix test more

* Add: `limited_visibility`に`Reply`を追加、`context`のURI

* Add: 外部からの`context`受信処理

* Fix test

* Add: 公開範囲「返信」

* Fix test

* Fix: 返信に返信以外の公開範囲を設定できない問題

* Add: ローカル投稿時にメンション追加・他サーバーへの転送

* Fix test

* Fix test

* Test: ローカルスレッドへの返信投稿の転送

* Test: 未知のアカウントからのメンション

* Add: 編集・削除の連合に対応

* Remove: 重複テスト

* Fix: 改善

* Add: 編集削除の転送処理・返信なのにsilentなメンションでの通知

* Fix: リプライが第三者に届かない問題

* Add: `always_sign_unsafe`

* Add: Subject

* Remove space

* Fix: 他人のスレッドの送信先一覧を非表示

* Fix: おかしいコード
This commit is contained in:
KMY(雪あすか) 2023-11-30 09:29:24 +09:00 committed by GitHub
parent a52a8ce214
commit a88349af55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1115 additions and 77 deletions

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class ActivityPub::ContextsController < ActivityPub::BaseController
include SignatureVerification
vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :set_context
def show
expires_in 3.minutes, public: true
render json: @context,
serializer: ActivityPub::ContextSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
private
def set_context
@context = Conversation.find(params[:id])
end
end

View file

@ -61,16 +61,6 @@ export const defaultMediaVisibility = (status) => {
};
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});

View file

@ -76,16 +76,6 @@ export const defaultMediaVisibility = (status) => {
};
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});

View file

@ -336,7 +336,7 @@ class StatusActionBar extends ImmutablePureComponent {
}
if (signedIn) {
if (writtenByMe) {
if (writtenByMe && status.get('limited_scope') !== 'reply') {
menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions });
}

View file

@ -8,6 +8,7 @@ import { ReactComponent as LoginIcon } from '@material-symbols/svg-600/outlined/
import { ReactComponent as LockIcon } from '@material-symbols/svg-600/outlined/lock.svg';
import { ReactComponent as LockOpenIcon } from '@material-symbols/svg-600/outlined/no_encryption.svg';
import { ReactComponent as PublicIcon } from '@material-symbols/svg-600/outlined/public.svg';
import { ReactComponent as ReplyIcon } from '@material-symbols/svg-600/outlined/reply.svg';
import { ReactComponent as LimitedIcon } from '@material-symbols/svg-600/outlined/shield.svg';
import { ReactComponent as PersonalIcon } from '@material-symbols/svg-600/outlined/sticky_note.svg';
@ -23,6 +24,7 @@ type Visibility =
| 'mutual'
| 'circle'
| 'personal'
| 'reply'
| 'limited';
const messages = defineMessages({
@ -49,6 +51,10 @@ const messages = defineMessages({
id: 'privacy.circle.short',
defaultMessage: 'Circle members only',
},
reply_short: {
id: 'privacy.reply.short',
defaultMessage: 'Reply',
},
personal_short: {
id: 'privacy.personal.short',
defaultMessage: 'Yourself only',
@ -105,6 +111,11 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
iconComponent: CircleIcon,
text: intl.formatMessage(messages.circle_short),
},
reply: {
icon: 'reply',
iconComponent: ReplyIcon,
text: intl.formatMessage(messages.reply_short),
},
personal: {
icon: 'sticky-note-o',
iconComponent: PersonalIcon,

View file

@ -14,6 +14,7 @@ import { ReactComponent as LoginIcon } from '@material-symbols/svg-600/outlined/
import { ReactComponent as LockIcon } from '@material-symbols/svg-600/outlined/lock.svg';
import { ReactComponent as LockOpenIcon } from '@material-symbols/svg-600/outlined/no_encryption.svg';
import { ReactComponent as PublicIcon } from '@material-symbols/svg-600/outlined/public.svg';
import { ReactComponent as ReplyIcon } from '@material-symbols/svg-600/outlined/reply.svg';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
@ -38,6 +39,8 @@ const messages = defineMessages({
mutual_long: { id: 'privacy.mutual.long', defaultMessage: 'Mutual follows only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle' },
circle_long: { id: 'privacy.circle.long', defaultMessage: 'Circle members only' },
reply_short: { id: 'privacy.reply.short', defaultMessage: 'Reply' },
reply_long: { id: 'privacy.reply.long', defaultMessage: 'Reply to limited post' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
@ -166,6 +169,7 @@ class PrivacyDropdown extends PureComponent {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,
replyToLimited: PropTypes.bool,
container: PropTypes.func,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
@ -280,10 +284,22 @@ class PrivacyDropdown extends PureComponent {
};
render () {
const { value, container, disabled, intl } = this.props;
const { value, container, disabled, intl, replyToLimited } = this.props;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value) || this.options[0];
if (replyToLimited) {
if (!this.selectableOptions.some((op) => op.value === 'reply')) {
this.selectableOptions.unshift(
{ icon: 'reply', iconComponent: ReplyIcon, value: 'reply', text: intl.formatMessage(messages.reply_short), meta: intl.formatMessage(messages.reply_long) },
);
}
} else {
if (this.selectableOptions.some((op) => op.value === 'reply')) {
this.selectableOptions = this.selectableOptions.filter((op) => op.value !== 'reply');
}
}
const valueOption = this.selectableOptions.find(item => item.value === value) || this.selectableOptions[0];
return (
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>

View file

@ -7,6 +7,7 @@ import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({
value: state.getIn(['compose', 'privacy']),
replyToLimited: state.getIn(['compose', 'reply_to_limited']),
});
const mapDispatchToProps = dispatch => ({

View file

@ -287,7 +287,10 @@ class ActionBar extends PureComponent {
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions });
if (status.get('limited_scope') !== 'reply') {
menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions });
}
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });

View file

@ -77,6 +77,7 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
reply_to_limited: false,
is_composing: false,
is_submitting: false,
is_changing_upload: false,
@ -114,6 +115,10 @@ const initialPoll = ImmutableMap({
});
function statusToTextMentions(state, status) {
if (status.get('visibility_ex') === 'limited') {
return '';
}
let set = ImmutableOrderedSet([]);
if (status.getIn(['account', 'id']) !== me) {
@ -144,6 +149,7 @@ function clearAll(state) {
if (!state.get('in_reply_to')) {
map.set('posted_on_this_session', true);
}
map.set('reply_to_limited', false);
map.set('limited_scope', null);
map.set('id', null);
map.set('in_reply_to', null);
@ -411,7 +417,12 @@ export default function compose(state = initialState, action) {
map.set('id', null);
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility_ex'), state.get('default_privacy')));
map.set('reply_to_limited', action.status.get('visibility_ex') === 'limited');
if (action.status.get('visibility_ex') === 'limited') {
map.set('privacy', 'reply');
} else {
map.set('privacy', privacyPreference(action.status.get('visibility_ex'), state.get('default_privacy')));
}
map.set('limited_scope', null);
map.set('searchability', privacyPreference(action.status.get('searchability'), state.get('default_searchability')));
map.set('focusDate', new Date());
@ -521,7 +532,11 @@ export default function compose(state = initialState, action) {
return state.set('tagHistory', fromJS(action.tags));
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
if (state.get('privacy') === 'reply') {
return state.set('in_reply_to', null).set('privacy', 'circle');
} else {
return state.set('in_reply_to', null);
}
} else if (action.id === state.get('id')) {
return state.set('id', null);
} else {
@ -549,6 +564,7 @@ export default function compose(state = initialState, action) {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility_ex'));
map.set('reply_to_limited', action.status.get('limited_scope') === 'reply');
map.set('limited_scope', null);
map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true)));
map.set('focusDate', new Date());
@ -583,8 +599,9 @@ export default function compose(state = initialState, action) {
if (action.status.get('visibility_ex') !== 'limited') {
map.set('privacy', action.status.get('visibility_ex'));
} else {
map.set('privacy', action.status.get('limited_scope') === 'mutual' ? 'mutual' : 'circle');
map.set('privacy', action.status.get('limited_scope') || 'circle');
}
map.set('reply_to_limited', action.status.get('limited_scope') === 'reply');
map.set('limited_scope', action.status.get('limited_scope'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date());

View file

@ -93,6 +93,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
resolve_thread(@status)
fetch_replies(@status)
process_conversation! if @status.limited_visibility?
process_references!
distribute
forward_for_reply
@ -132,7 +133,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
limited_scope: @status_parser.limited_scope,
searchability: @status_parser.searchability,
thread: replied_to_status,
conversation: conversation_from_uri(@object['conversation']),
conversation: conversation_from_activity,
media_attachment_ids: process_attachments.take(MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX).map(&:id),
poll: process_poll,
}
@ -184,6 +185,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@silenced_account_ids = @mentions.map(&:account_id) - accounts_in_audience.map(&:id)
end
def account_representative
accounts_in_audience.detect(&:local?) || Account.representative
end
def postprocess_audience_and_deliver
return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id])
@ -373,6 +378,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
ActivityPub::FetchRepliesWorker.perform_async(status.id, uri, { 'request_id' => @options[:request_id] }) unless uri.nil?
end
def conversation_from_activity
conversation_from_context(@object['context']) || conversation_from_uri(@object['conversation'])
end
def conversation_from_uri(uri)
return nil if uri.nil?
return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
@ -384,6 +393,26 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def conversation_from_context(uri)
return nil if uri.nil?
return Conversation.find_by(id: ActivityPub::TagManager.instance.uri_to_local_id(uri)) if ActivityPub::TagManager.instance.local_uri?(uri)
begin
conversation = Conversation.find_or_create_by!(uri: uri)
json = fetch_resource_without_id_validation(uri, account_representative)
return conversation if json.nil? || json['type'] != 'Group'
return conversation if json['inbox'].blank? || json['inbox'] == conversation.inbox_url
conversation.update!(inbox_url: json['inbox'])
conversation
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
retry
rescue Mastodon::UnexpectedResponseError
Conversation.find_or_create_by!(uri: uri)
end
end
def replied_to_status
return @replied_to_status if defined?(@replied_to_status)
@ -483,6 +512,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
end
def process_conversation!
return unless @status.conversation.present? && @status.conversation.local?
ProcessConversationService.new.call(@status)
return if @json['signature'].blank?
ActivityPub::ForwardConversationWorker.perform_async(Oj.dump(@json), @status.id, false)
end
def increment_voters_count!
poll = replied_to_status.preloadable_poll

View file

@ -40,10 +40,17 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
forward_for_conversation
delete_now!
end
end
def forward_for_conversation
return unless @status.conversation.present? && @status.conversation.local? && @json['signature'].present?
ActivityPub::ForwardConversationWorker.perform_async(Oj.dump(@json), @status.id, true)
end
def delete_friend
friend = FriendDomain.find_by(domain: @account.domain)
friend&.destroy

View file

@ -31,5 +31,13 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
return if @status.nil?
ActivityPub::ProcessStatusUpdateService.new.call(@status, @json, @object, request_id: @options[:request_id])
forward_for_conversation
end
def forward_for_conversation
return unless @status.conversation.present? && @status.conversation.local? && @json['signature'].present?
ActivityPub::ForwardConversationWorker.perform_async(Oj.dump(@json), @status.id, true)
end
end

View file

@ -105,6 +105,8 @@ class ActivityPub::Parser::StatusParser
:mutual
when 'Circle'
:circle
when 'Reply'
:reply
else
:none
end

View file

@ -49,6 +49,8 @@ class ActivityPub::TagManager
emoji_url(target)
when :emoji_reaction
emoji_reaction_url(target)
when :conversation
context_url(target)
when :flag
target.uri
end
@ -119,7 +121,8 @@ class ActivityPub::TagManager
end.compact
end
when 'limited'
['kmyblue:Limited'] # to avoid Fedibird personal visibility
# do not empty array to avoid Fedibird personal visibility
status.conversation.nil? ? ['kmyblue:Limited'] : [context_url(status.conversation)]
end
end
@ -225,10 +228,15 @@ class ActivityPub::TagManager
end
def limited_scope(status)
if status.mutual_limited?
case status.limited_scope
when 'mutual'
'Mutual'
when 'circle'
'Circle'
when 'reply'
'Reply'
else
status.circle_limited? ? 'Circle' : ''
''
end
end
@ -250,8 +258,6 @@ class ActivityPub::TagManager
[COLLECTIONS[:public]]
when 'private'
[account_followers_url(status.account)]
when 'direct'
status.conversation_id.present? ? [uri_for(status.conversation)] : []
when 'limited'
['as:Limited', 'kmyblue:Limited']
else
@ -271,7 +277,7 @@ class ActivityPub::TagManager
case account.compute_searchability_activitypub
when 'public'
[COLLECTIONS[:public]]
when 'private', 'direct'
when 'private'
[account_followers_url(account)]
when 'limited'
['as:Limited', 'kmyblue:Limited']

View file

@ -409,9 +409,9 @@ class FeedManager
# @param [Integer] receiver_id
# @param [Hash] crutches
# @return [Boolean]
def filter_from_home?(status, receiver_id, crutches, timeline_type = :home, stl_home: false)
def filter_from_home?(status, receiver_id, crutches, timeline_type = :home, stl_home: false) # rubocop:disable Metrics/PerceivedComplexity
return false if receiver_id == status.account_id
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) && !(timeline_type == :home && status.limited_visibility?)
return true if (timeline_type != :list || stl_home) && (crutches[:exclusive_list_users][status.account_id].present? || crutches[:exclusive_antenna_users][status.account_id].present?)
return true if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
@ -426,10 +426,11 @@ class FeedManager
return true if check_for_blocks.any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] }
return true if crutches[:blocked_by][status.account_id]
if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply
should_filter = !crutches[:following][status.in_reply_to_account_id] # and I'm not following the person it's a reply to
should_filter &&= receiver_id != status.in_reply_to_account_id # and it's not a reply to me
should_filter &&= status.account_id != status.in_reply_to_account_id # and it's not a self-reply
if status.reply? && (!status.in_reply_to_account_id.nil? || (status.thread.present? && status.limited_visibility?)) # Filter out if it's a reply
account_id = status.in_reply_to_account_id || status.thread.account_id
should_filter = !crutches[:following][account_id] # and I'm not following the person it's a reply to
should_filter &&= receiver_id != account_id # and it's not a reply to me
should_filter &&= status.account_id != account_id # and it's not a self-reply
return !!should_filter
elsif status.reblog? # Filter out a reblog
@ -607,7 +608,10 @@ class FeedManager
lists = List.where(account_id: receiver_id, exclusive: true)
antennas = Antenna.where(list: lists, insert_feeds: true)
crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map(&:in_reply_to_account_id)).pluck(:target_account_id).index_with(true)
replied_accounts = statuses.filter_map(&:in_reply_to_account_id)
replied_accounts += statuses.filter { |status| status.limited_visibility? && status.thread.present? }.map { |status| status.thread.account_id }
crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: replied_accounts).pluck(:target_account_id).index_with(true)
crutches[:languages] = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h
crutches[:hiding_reblogs] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map { |s| s.account_id if s.reblog? }, show_reblogs: false).pluck(:target_account_id).index_with(true)
crutches[:blocking] = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true)

View file

@ -51,22 +51,18 @@ class StatusReachFinder
end
def reached_account_inboxes_for_misskey
if @status.reblog?
if @status.reblog? || @status.limited_visibility?
[]
elsif @status.limited_visibility?
Account.where(id: mentioned_account_ids).where(domain: banned_domains_for_misskey).inboxes
else
Account.where(id: reached_account_ids).where(domain: banned_domains_for_misskey - friend_domains).inboxes
Account.where(id: reached_account_ids, domain: banned_domains_for_misskey - friend_domains).inboxes
end
end
def reached_account_inboxes_for_friend
if @status.reblog?
if @status.reblog? || @status.limited_visibility?
[]
elsif @status.limited_visibility?
Account.where(id: mentioned_account_ids).where.not(domain: banned_domains).inboxes
else
Account.where(id: reached_account_ids, domain: friend_domains).where.not(domain: banned_domains - friend_domains).inboxes
Account.where(id: reached_account_ids, domain: friend_domains).inboxes
end
end

View file

@ -4,18 +4,25 @@
#
# Table name: conversations
#
# id :bigint(8) not null, primary key
# uri :string
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint(8) not null, primary key
# uri :string
# created_at :datetime not null
# updated_at :datetime not null
# inbox_url :string
# ancestor_status_id :bigint(8)
#
class Conversation < ApplicationRecord
validates :uri, uniqueness: true, if: :uri?
has_many :statuses
belongs_to :ancestor_status, class_name: 'Status', inverse_of: :owned_conversation, optional: true
def local?
uri.nil?
end
def object_type
:conversation
end
end

View file

@ -59,13 +59,14 @@ class Status < ApplicationRecord
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility
enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability
enum limited_scope: { none: 0, mutual: 1, circle: 2, personal: 3 }, _suffix: :limited
enum limited_scope: { none: 0, mutual: 1, circle: 2, personal: 3, reply: 4 }, _suffix: :limited
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
belongs_to :account, inverse_of: :statuses
belongs_to :in_reply_to_account, class_name: 'Account', optional: true
belongs_to :conversation, optional: true
has_one :owned_conversation, class_name: 'Conversation', foreign_key: 'ancestor_status_id', dependent: :nullify, inverse_of: false
belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true, inverse_of: false
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
@ -83,6 +84,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :silent_mentions, -> { silent }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
has_many :reference_objects, class_name: 'StatusReference', inverse_of: :status, dependent: :destroy
has_many :references, through: :reference_objects, class_name: 'Status', source: :target_status
@ -658,11 +660,16 @@ class Status < ApplicationRecord
self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
if reply? && !thread.nil?
if reply? && !thread.nil? && (!limited_visibility? || none_limited? || reply_limited?)
self.in_reply_to_account_id = carried_over_reply_to_account_id
self.conversation_id = thread.conversation_id if conversation_id.nil?
elsif conversation_id.nil?
self.conversation = Conversation.new
if local?
self.owned_conversation = Conversation.new
self.conversation = owned_conversation
else
self.conversation = Conversation.new
end
end
end

View file

@ -25,7 +25,7 @@ class StatusPolicy < ApplicationPolicy
end
def show_mentioned_users?
owned?
record.limited_visibility? ? owned_conversation? : owned?
end
def reblog?
@ -64,6 +64,11 @@ class StatusPolicy < ApplicationPolicy
author.id == current_account&.id
end
def owned_conversation?
record.conversation&.local? &&
(record.conversation.ancestor_status.nil? ? owned? : record.conversation.ancestor_status.account_id == current_account&.id)
end
def private?
record.private_visibility?
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class ActivityPub::ContextSerializer < ActivityPub::Serializer
include RoutingHelper
attributes :id, :type, :inbox
def id
ActivityPub::TagManager.instance.uri_for(object)
end
def type
'Group'
end
def inbox
return '' if object.ancestor_status.nil?
account_inbox_url(object.ancestor_status.account)
end
end

View file

@ -9,11 +9,12 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:in_reply_to, :published, :url,
:attributed_to, :to, :cc, :sensitive,
:atom_uri, :in_reply_to_atom_uri,
:conversation, :searchable_by, :limited_scope
:conversation, :searchable_by, :context
attribute :content
attribute :content_map, if: :language?
attribute :updated, if: :edited?
attribute :limited_scope, if: :limited_visibility?
attribute :quote_uri, if: :quote?
attribute :misskey_quote, key: :_misskey_quote, if: :quote?
@ -52,6 +53,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
{ object.language => content }
end
def context
ActivityPub::TagManager.instance.uri_for(object.conversation)
end
def replies
replies = object.self_replies(5).pluck(:id, :uri)
last_id = replies.last&.first
@ -88,6 +93,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
object.language.present?
end
delegate :limited_visibility?, to: :object
delegate :edited?, to: :object
def in_reply_to

View file

@ -14,11 +14,12 @@ module Payloadable
signer = options.delete(:signer)
sign_with = options.delete(:sign_with)
always_sign = options.delete(:always_sign)
always_sign_unsafe = options.delete(:always_sign_unsafe)
payload = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json
object = record.respond_to?(:virtual_object) ? record.virtual_object : record
bearcap = object.is_a?(String) && record.respond_to?(:type) && (record.type == 'Create' || record.type == 'Update')
if ((object.respond_to?(:sign?) && object.sign?) && signer && (always_sign || signing_enabled?)) || bearcap
if ((object.respond_to?(:sign?) && object.sign?) && signer && (always_sign || signing_enabled?)) || bearcap || (signer && always_sign_unsafe)
ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with)
else
payload

View file

@ -46,6 +46,7 @@ class FanOutOnWriteService < BaseService
unless @options[:skip_notifications]
notify_mentioned_accounts!
notify_for_conversation! if @status.limited_visibility?
notify_about_update! if update?
end
@ -93,6 +94,17 @@ class FanOutOnWriteService < BaseService
end
end
def notify_for_conversation!
return if @status.conversation.nil?
account_ids = @status.conversation.statuses.pluck(:account_id).uniq.reject { |account_id| account_id == @status.account_id }
@status.silent_mentions.where(account_id: account_ids).joins(:account).merge(Account.local).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
LocalNotificationWorker.push_bulk(mentions) do |mention|
[mention.account_id, mention.id, 'Mention', 'mention']
end
end
end
def notify_about_update!
@status.reblogged_by_accounts.or(@status.quoted_by_accounts).merge(Account.local).select(:id).reorder(nil).find_in_batches do |accounts|
LocalNotificationWorker.push_bulk(accounts) do |account|

View file

@ -75,11 +75,10 @@ class PostStatusService < BaseService
end) || @options[:spoiler_text].present?
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@visibility = @options[:visibility]&.to_sym || @account.user&.setting_default_privacy&.to_sym
@visibility = :direct if @in_reply_to&.limited_visibility?
@visibility = :limited if %w(mutual circle).include?(@options[:visibility])
@visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted || @visibility&.to_sym == :login) && @account.silenced?
@visibility = :public_unlisted if @visibility&.to_sym == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility
@limited_scope = @options[:visibility]&.to_sym if @visibility == :limited
@visibility = :limited if %w(mutual circle reply).include?(@options[:visibility])
@visibility = :unlisted if (@visibility == :public || @visibility == :public_unlisted || @visibility == :login) && @account.silenced?
@visibility = :public_unlisted if @visibility == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility
@limited_scope = @options[:visibility]&.to_sym if @visibility == :limited && @options[:visibility] != 'limited'
@searchability = searchability
@searchability = :private if @account.silenced? && %i(public public_unlisted).include?(@searchability&.to_sym)
@markdown = @options[:markdown] || false
@ -88,6 +87,11 @@ class PostStatusService < BaseService
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
raise ArgumentError if !Setting.enable_public_unlisted_visibility && @visibility == :public_unlisted
if @in_reply_to.present? && ((@options[:visibility] == 'limited' && @options[:circle_id].nil?) || @limited_scope == :reply)
@visibility = :limited
@limited_scope = :reply
end
load_circle
overwrite_dtl_post
process_sensitive_words
@ -96,8 +100,9 @@ class PostStatusService < BaseService
end
def load_circle
raise ArgumentError if @options[:visibility] == 'limited' && @options[:circle_id].nil?
return unless @options[:visibility] == 'circle' || (@options[:visibility] == 'limited' && @options[:circle_id].present?)
return if @visibility == :limited && @limited_scope == :reply && @in_reply_to.present?
return unless %w(circle limited reply).include?(@options[:visibility])
raise ArgumentError if @options[:circle_id].nil?
@circle = @options[:circle_id].present? && Circle.find(@options[:circle_id])
@limited_scope = :circle
@ -148,7 +153,7 @@ class PostStatusService < BaseService
safeguard_mentions!(@status)
validate_status_mentions!
@status.limited_scope = :personal if @status.limited_visibility? && !process_mentions_service.mentions?
@status.limited_scope = :personal if @status.limited_visibility? && !@status.reply_limited? && !process_mentions_service.mentions?
UpdateStatusExpirationService.new.call(@status)
@ -196,6 +201,7 @@ class PostStatusService < BaseService
process_hashtags_service.call(@status)
Trends.tags.register(@status)
ProcessConversationService.new.call(@status) if @status.limited_visibility? && @status.reply_limited?
ProcessReferencesService.call_service(@status, @reference_ids, [])
LinkCrawlWorker.perform_async(@status.id)
DistributionWorker.perform_async(@status.id)

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class ProcessConversationService < BaseService
def call(status)
@status = status
return if !@status.limited_visibility? || @status.conversation.nil?
duplicate_reply!
end
private
def thread
@thread ||= @status.thread || @status.conversation.ancestor_status
end
def duplicate_reply!
return unless @status.conversation.local?
return if !@status.reply? || thread.nil?
return if thread.conversation_id != @status.conversation_id
mentioned_account_ids = @status.mentions.pluck(:account_id)
thread.mentioned_accounts.find_each do |account|
@status.mentions << @status.mentions.new(silent: true, account: account) unless mentioned_account_ids.include?(account.id)
mentioned_account_ids << account.id
end
@status.mentions << @status.mentions.new(silent: true, account: thread.account) unless mentioned_account_ids.include?(thread.account.id)
@status.save!
end
end

View file

@ -106,6 +106,8 @@ class RemoveStatusService < BaseService
# the author and wouldn't normally receive the delete
# notification - so here, we explicitly send it to them
return remove_from_conversation if @status.limited_visibility? && @status.conversation.present? && !@status.conversation.local?
status_reach_finder = StatusReachFinder.new(@status, unsafe: true)
ActivityPub::DeliveryWorker.push_bulk(status_reach_finder.all_inboxes, limit: 1_000) do |inbox_url|
@ -113,8 +115,14 @@ class RemoveStatusService < BaseService
end
end
def remove_from_conversation
return if @status.conversation.nil? || @status.conversation.inbox_url.blank?
ActivityPub::DeliveryWorker.perform_async(signed_activity_json, @account.id, @status.conversation.inbox_url)
end
def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account, always_sign: true))
@signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account, always_sign_unsafe: @status.limited_visibility?))
end
def remove_reblogs

View file

@ -19,11 +19,26 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
protected
def distribute_limited!
if @status.reply? && @status.conversation.present? && !@status.conversation.local?
distribute_conversation!
else
distribute_limited_mentions!
end
end
def distribute_limited_mentions!
ActivityPub::DeliveryWorker.push_bulk(inboxes_for_limited, limit: 1_000) do |inbox_url|
[payload, @account.id, inbox_url, options]
end
end
def distribute_conversation!
inbox_url = @status.conversation.inbox_url
return if inbox_url.blank?
ActivityPub::DeliveryWorker.perform_async(payload, @account.id, inbox_url, options)
end
def inboxes
@inboxes ||= status_reach_finder.inboxes
end
@ -45,7 +60,7 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
end
def payload
@payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account))
@payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account, always_sign_unsafe: always_sign))
end
def payload_for_misskey
@ -53,7 +68,7 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
end
def payload_for_friend
@payload_for_friend ||= Oj.dump(serialize_payload(activity_for_friend, ActivityPub::ActivityForFriendSerializer, signer: @account))
@payload_for_friend ||= Oj.dump(serialize_payload(activity_for_friend, ActivityPub::ActivityForFriendSerializer, signer: @account, always_sign_unsafe: always_sign))
end
def activity
@ -68,6 +83,10 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
ActivityPub::ActivityPresenter.from_status(@status, for_friend: true)
end
def always_sign
false
end
def options
{ 'synchronize_followers' => @status.private_visibility? }
end

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class ActivityPub::ForwardConversationWorker
include Sidekiq::Worker
def perform(payload, status_id, shared_inbox)
@status = Status.find(status_id)
@payload = payload
@shared_inbox = shared_inbox
return unless @status.conversation.present? && @status.conversation.local? && @status.conversation.ancestor_status.present?
return unless @status.limited_visibility?
@account = @status.conversation.ancestor_status.account
distribute_limited_mentions!
rescue ActiveRecord::RecordNotFound
true
end
protected
def distribute_limited_mentions!
ActivityPub::DeliveryWorker.push_bulk(inboxes_for_limited, limit: 1_000) do |inbox_url|
[payload, @account.id, inbox_url, options]
end
end
def inboxes_for_limited
if @shared_inbox
inbox_accounts.inboxes
else
DeliveryFailureTracker.without_unavailable(inbox_accounts.pluck(:inbox_url).compact_blank.uniq)
end
end
def inbox_accounts
Account.remote.merge(@status.mentioned_accounts)
end
def options
{ 'synchronize_followers' => @status.private_visibility? }
end
attr_reader :payload
end

View file

@ -8,13 +8,21 @@ class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWor
@status = Status.find(status_id)
@account = @status.account
distribute!
if @status.limited_visibility?
distribute_limited!
else
distribute!
end
rescue ActiveRecord::RecordNotFound
true
end
protected
def inboxes_for_limited
@inboxes_for_limited ||= @status.mentioned_accounts.inboxes
end
def build_activity(for_misskey: false, for_friend: false)
ActivityPub::ActivityPresenter.new(
id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @status.edited_at.to_i].join,
@ -38,4 +46,8 @@ class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWor
def activity_for_friend
build_activity(for_friend: true)
end
def always_sign
@status.limited_visibility?
end
end