-
+
{
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ };
+
+ handleKeyDown = e => {
+ const { items } = this.props;
+ const value = e.currentTarget.getAttribute('data-index');
+ const index = items.findIndex(item => {
+ return (item.value === value);
+ });
+ let element = null;
+
+ switch(e.key) {
+ case 'Escape':
+ this.props.onClose();
+ break;
+ case 'Enter':
+ this.handleClick(e);
+ break;
+ case 'ArrowDown':
+ element = this.node.childNodes[index + 1] || this.node.firstChild;
+ break;
+ case 'ArrowUp':
+ element = this.node.childNodes[index - 1] || this.node.lastChild;
+ break;
+ case 'Tab':
+ if (e.shiftKey) {
+ element = this.node.childNodes[index - 1] || this.node.lastChild;
+ } else {
+ element = this.node.childNodes[index + 1] || this.node.firstChild;
+ }
+ break;
+ case 'Home':
+ element = this.node.firstChild;
+ break;
+ case 'End':
+ element = this.node.lastChild;
+ break;
+ }
+
+ if (element) {
+ element.focus();
+ this.props.onChange(element.getAttribute('data-index'));
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ };
+
+ handleClick = e => {
+ const value = e.currentTarget.getAttribute('data-index');
+
+ e.preventDefault();
+
+ this.props.onClose();
+ this.props.onChange(value);
+ };
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ };
+
+ setFocusRef = c => {
+ this.focusedItem = c;
+ };
+
+ render () {
+ const { style, items, value } = this.props;
+
+ return (
+
+ {items.map(item => (
+
+
+
+
+
+
+ {item.text}
+ {item.meta}
+
+
+ ))}
+
+ );
+ }
+
+}
+
+class SearchabilityDropdown extends React.PureComponent {
+
+ static propTypes = {
+ isUserTouching: PropTypes.func,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ noDirect: PropTypes.bool,
+ container: PropTypes.func,
+ disabled: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ open: false,
+ placement: 'bottom',
+ };
+
+ handleToggle = () => {
+ if (this.props.isUserTouching && this.props.isUserTouching()) {
+ if (this.state.open) {
+ this.props.onModalClose();
+ } else {
+ this.props.onModalOpen({
+ actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
+ onClick: this.handleModalActionClick,
+ });
+ }
+ } else {
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+ this.setState({ open: !this.state.open });
+ }
+ };
+
+ handleModalActionClick = (e) => {
+ e.preventDefault();
+
+ const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+
+ this.props.onModalClose();
+ this.props.onChange(value);
+ };
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'Escape':
+ this.handleClose();
+ break;
+ }
+ };
+
+ handleMouseDown = () => {
+ if (!this.state.open) {
+ this.activeElement = document.activeElement;
+ }
+ };
+
+ handleButtonKeyDown = (e) => {
+ switch(e.key) {
+ case ' ':
+ case 'Enter':
+ this.handleMouseDown();
+ break;
+ }
+ };
+
+ handleClose = () => {
+ if (this.state.open && this.activeElement) {
+ this.activeElement.focus({ preventScroll: true });
+ }
+ this.setState({ open: false });
+ };
+
+ handleChange = value => {
+ this.props.onChange(value);
+ };
+
+ componentWillMount () {
+ const { intl: { formatMessage } } = this.props;
+
+ this.options = [
+ { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
+ { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+ { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
+ { icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+ ];
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ };
+
+ findTarget = () => {
+ return this.target;
+ };
+
+ handleOverlayEnter = (state) => {
+ this.setState({ placement: state.placement });
+ };
+
+ render () {
+ const { value, container, disabled, intl } = this.props;
+ const { open, placement } = this.state;
+
+ const valueOption = this.options.find(item => item.value === value);
+
+ return (
+
+
+
+
+
+
+
+ {({ props, placement }) => (
+
+ )}
+
+
+ );
+ }
+
+}
+
+export default injectIntl(SearchabilityDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js
index 2243aa70be..5de1ccb3e6 100644
--- a/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/expiration_dropdown_container.js
@@ -1,6 +1,5 @@
import { connect } from 'react-redux';
import ExpirationDropdown from '../components/expiration_dropdown';
-import { changeComposeVisibility } from '../../../actions/compose';
import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile';
diff --git a/app/javascript/mastodon/features/compose/containers/searchability_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/searchability_dropdown_container.js
new file mode 100644
index 0000000000..3b4a83a1f0
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/searchability_dropdown_container.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import SearchabilityDropdown from '../components/searchability_dropdown';
+import { changeComposeSearchability } from '../../../actions/compose';
+import { openModal, closeModal } from '../../../actions/modal';
+import { isUserTouching } from '../../../is_mobile';
+
+const mapStateToProps = state => ({
+ value: state.getIn(['compose', 'searchability']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+ onChange (value) {
+ dispatch(changeComposeSearchability(value));
+ },
+
+ isUserTouching,
+ onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+ onModalClose: () => dispatch(closeModal()),
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchabilityDropdown);
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx
index f65aa531c0..116fac1d77 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.jsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx
@@ -25,6 +25,10 @@ const messages = defineMessages({
public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+ searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
+ searchability_unlisted_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
+ searchability_private_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' },
+ searchability_direct_short: { id: 'searchability.direct.short', defaultMessage: 'Self only' },
});
class DetailedStatus extends ImmutablePureComponent {
@@ -217,6 +221,16 @@ class DetailedStatus extends ImmutablePureComponent {
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
const visibilityLink = · ;
+ const searchabilityIconInfo = {
+ 'public': { icon: 'globe', text: intl.formatMessage(messages.searchability_public_short) },
+ 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.searchability_unlisted_short) },
+ 'private': { icon: 'lock', text: intl.formatMessage(messages.searchability_private_short) },
+ 'direct': { icon: 'at', text: intl.formatMessage(messages.searchability_direct_short) },
+ };
+
+ const searchabilityIcon = searchabilityIconInfo[status.get('searchability')];
+ const searchabilityLink = · ;
+
if (['private', 'direct'].includes(status.get('visibility'))) {
reblogLink = '';
} else if (this.context.router) {
@@ -318,7 +332,7 @@ class DetailedStatus extends ImmutablePureComponent {
- {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
+ {edited}{visibilityLink}{searchabilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 36ee558179..3c29206f6c 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -533,6 +533,15 @@
"search.quick_action.open_url": "Open URL in Mastodon",
"search.quick_action.status_search": "Posts matching {x}",
"search.search_or_paste": "Search or paste URL",
+ "searchability.change": "Change post searchability",
+ "searchability.direct.long": "Nobody can find, but you can",
+ "searchability.direct.short": "Self only",
+ "searchability.private.long": "Reacter of this post can find",
+ "searchability.private.short": "Reactionners",
+ "searchability.public.long": "Anyone can find",
+ "searchability.public.short": "Public",
+ "searchability.unlisted.long": "Your followers can find",
+ "searchability.unlisted.short": "Followers",
"search_popout.quick_actions": "Quick actions",
"search_popout.recent": "Recent searches",
"search_results.accounts": "Profiles",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 3022a185af..3a3f857174 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -533,6 +533,15 @@
"search.quick_action.open_url": "Open URL in Mastodon",
"search.quick_action.status_search": "Posts matching {x}",
"search.search_or_paste": "検索またはURLを入力",
+ "searchability.change": "検索許可範囲を変更",
+ "searchability.direct.long": "この投稿はあなたしか検索できません",
+ "searchability.direct.short": "自分のみ",
+ "searchability.private.long": "この投稿にリアクションした人だけが検索できます",
+ "searchability.private.short": "リアクションした人のみ",
+ "searchability.public.long": "この投稿は誰でも検索できます",
+ "searchability.public.short": "全て",
+ "searchability.unlisted.long": "この投稿はあなたのフォロワーしか検索できません",
+ "searchability.unlisted.short": "フォロワーのみ",
"search_popout.quick_actions": "Quick actions",
"search_popout.recent": "Recent searches",
"search_results.accounts": "ユーザー",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 1483f1c2e4..d499f9283e 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -47,6 +47,7 @@ import {
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS,
COMPOSE_SET_STATUS,
+ COMPOSE_SEARCHABILITY_CHANGE,
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store';
@@ -62,6 +63,7 @@ const initialState = ImmutableMap({
spoiler: false,
spoiler_text: '',
privacy: null,
+ searchability: null,
id: null,
text: '',
focusDate: null,
@@ -81,6 +83,7 @@ const initialState = ImmutableMap({
suggestion_token: null,
suggestions: ImmutableList(),
default_privacy: 'public',
+ default_searchability: 'public',
default_sensitive: false,
default_language: 'en',
resetFileKey: Math.floor((Math.random() * 0x10000)),
@@ -121,6 +124,7 @@ function clearAll(state) {
map.set('is_changing_upload', false);
map.set('in_reply_to', null);
map.set('privacy', state.get('default_privacy'));
+ map.set('searchability', state.get('default_searchability'));
map.set('sensitive', state.get('default_sensitive'));
map.set('language', state.get('default_language'));
map.update('media_attachments', list => list.clear());
@@ -328,6 +332,10 @@ export default function compose(state = initialState, action) {
return state
.set('privacy', action.value)
.set('idempotencyKey', uuid());
+ case COMPOSE_SEARCHABILITY_CHANGE:
+ return state
+ .set('searchability', action.value)
+ .set('idempotencyKey', uuid());
case COMPOSE_CHANGE:
return state
.set('text', action.text)
@@ -340,6 +348,7 @@ export default function compose(state = initialState, action) {
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+ map.set('searchability', privacyPreference(action.status.get('searchability'), state.get('default_searchability')));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('preselectDate', new Date());
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 7498477caa..4dd5e63612 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -289,7 +289,8 @@ html {
background: $ui-base-color;
}
-.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {
+.privacy-dropdown.active .privacy-dropdown__value.active .icon-button,
+.expiration-dropdown.active .expiration-dropdown__value.active .icon-button {
color: $white;
}
@@ -321,6 +322,14 @@ html {
.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
.privacy-dropdown__option:hover .privacy-dropdown__option__content,
.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
+.expiration-dropdown__option.active,
+.expiration-dropdown__option:hover,
+.expiration-dropdown__option.active .expiration-dropdown__option__content,
+.expiration-dropdown__option.active
+ .expiration-dropdown__option__content
+ strong,
+.expiration-dropdown__option:hover .expiration-dropdown__option__content,
+.expiration-dropdown__option:hover .expiration-dropdown__option__content strong,
.dropdown-menu__item a:active,
.dropdown-menu__item a:focus,
.dropdown-menu__item a:hover,
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e74e9c521b..0b1fa3b877 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -468,6 +468,13 @@ body > [data-popper-placement] {
inset-inline-end: 0;
}
+ .expiration-dropdown {
+ position: absolute;
+ top: 32px;
+ right: 4px;
+ transform: scale(1.2);
+ }
+
.compose-form__autosuggest-wrapper {
position: relative;
}
@@ -4695,7 +4702,8 @@ a.status-card.compact:hover {
filter: none;
}
-.privacy-dropdown__dropdown {
+.privacy-dropdown__dropdown,
+.expiration-dropdown__dropdown {
background: $simple-background-color;
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
border-radius: 4px;
@@ -4711,16 +4719,19 @@ a.status-card.compact:hover {
}
}
-.modal-root__container .privacy-dropdown {
+.modal-root__container .privacy-dropdown,
+.modal-root__container .expiration-dropdown {
flex-grow: 0;
}
-.modal-root__container .privacy-dropdown__dropdown {
+.modal-root__container .privacy-dropdown__dropdown,
+.modal-root__container .ezpiration-dropdown__dropdown {
pointer-events: auto;
z-index: 9999;
}
-.privacy-dropdown__option {
+.privacy-dropdown__option,
+.expiration-dropdown__option {
color: $inverted-text-color;
padding: 10px;
cursor: pointer;
@@ -4770,8 +4781,10 @@ a.status-card.compact:hover {
}
}
-.privacy-dropdown.active {
- .privacy-dropdown__value {
+.privacy-dropdown.active,
+.expiration-dropdown.active {
+ .privacy-dropdown__value,
+ .expiration-dropdown__value {
background: $simple-background-color;
border-radius: 4px 4px 0 0;
box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
@@ -4789,16 +4802,30 @@ a.status-card.compact:hover {
}
}
- &.top .privacy-dropdown__value {
+ &.top .privacy-dropdown__value,
+ &.top .expiration-dropdown__value {
border-radius: 0 0 4px 4px;
}
- .privacy-dropdown__dropdown {
+ .privacy-dropdown__dropdown,
+ .expiration-dropdown__dropdown {
display: block;
box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
}
}
+.privacy-dropdown .privacy-dropdown__value.searchability {
+ position: relative;
+
+ .searchability-dropdown__value-overlay {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ color: purple;
+ pointer-events: none;
+ }
+}
+
.language-dropdown {
&__dropdown {
background: $simple-background-color;
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 95f558f15e..9e08064191 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -70,6 +70,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
end
+ def audience_searchable_by
+ return nil if @object['searchableBy'].nil?
+
+ @audience_searchable_by = as_array(@object['searchableBy']).map { |x| value_or_id(x) }
+ end
+
def process_status
@tags = []
@mentions = []
@@ -122,6 +128,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
reply: @status_parser.reply,
sensitive: @account.sensitized? || @status_parser.sensitive || false,
visibility: @status_parser.visibility,
+ searchability: searchability,
thread: replied_to_status,
conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(4).map(&:id),
@@ -429,4 +436,54 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def join_group!
GroupReblogService.new.call(@status)
end
+
+ def searchability_from_audience
+ if audience_searchable_by.nil?
+ nil
+ elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) }
+ :public
+ elsif audience_searchable_by.include?(@account.followers_url)
+ :unlisted # Followers only in kmyblue (generics: private)
+ else
+ :private # Reaction only in kmyblue (generics: direct)
+ end
+ end
+
+ def searchability
+ searchability = searchability_from_audience
+
+ return nil if searchability.nil?
+
+ visibility = visibility_from_audience_with_silence
+
+ if searchability === visibility
+ searchability
+ elsif [:public, :unlisted].include?(searchability) && [:public, :unlisted].include?(visibility) # unlisted is Followers only in kmyblue (generics: private)
+ :unlisted
+ else
+ :private # Reaction only in kmyblue (generics: direct)
+ end
+ end
+
+ def visibility_from_audience
+ if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
+ :public
+ elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
+ :unlisted
+ elsif audience_to.include?(@account.followers_url)
+ :private
+ else
+ :direct
+ end
+ end
+
+ def visibility_from_audience_with_silence
+ visibility = visibility_from_audience
+
+ if @account.silenced? && %i(public).include?(visibility)
+ :unlisted
+ else
+ visibility
+ end
+ end
end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 4358172ae4..bd9706fc42 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -185,4 +185,53 @@ class ActivityPub::TagManager
rescue ActiveRecord::RecordNotFound
nil
end
+
+ def searchable_by(status)
+ searchable_by =
+ case status.compute_searchability
+ when 'public'
+ [COLLECTIONS[:public]]
+ when 'unlisted' # Followers only in kmyblue (generics: private)
+ [account_followers_url(status.account)]
+ when 'private' # Reaction only in kmyblue (generics: direct)
+ []
+ when 'limited'
+ status.conversation_id.present? ? [uri_for(status.conversation)] : []
+ else
+ []
+ end
+
+ searchable_by.concat(mentions_uris(status))
+ end
+
+ def account_searchable_by(account)
+ case account.searchability
+ when 'public'
+ [COLLECTIONS[:public]]
+ when 'unlisted', 'private'
+ [account_followers_url(account)]
+ else
+ []
+ end
+ end
+
+ def mentions_uris(status)
+ if status.account.silenced?
+ # Only notify followers if the account is locally silenced
+ account_ids = status.active_mentions.pluck(:account_id)
+ uris = status.account.followers.where(id: account_ids).each_with_object([]) do |account, result|
+ result << uri_for(account)
+ result << account_followers_url(account) if account.group?
+ end
+ uris.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).each_with_object([]) do |request, result|
+ result << uri_for(request.account)
+ result << account_followers_url(request.account) if request.account.group?
+ end)
+ else
+ status.active_mentions.each_with_object([]) do |mention, result|
+ result << uri_for(mention.account)
+ result << account_followers_url(mention.account) if mention.account.group?
+ end
+ end
+ end
end
diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb
index 05b818f73a..70656f9c8d 100644
--- a/app/lib/status_filter.rb
+++ b/app/lib/status_filter.rb
@@ -3,10 +3,11 @@
class StatusFilter
attr_reader :status, :account
- def initialize(status, account, preloaded_relations = {})
+ def initialize(status, account, preloaded_relations = {}, preloaded_status_relations = {})
@status = status
@account = account
@preloaded_relations = preloaded_relations
+ @preloaded_status_relations = preloaded_status_relations
end
def filtered?
@@ -15,12 +16,6 @@ class StatusFilter
blocked_by_policy? || (account_present? && filtered_status?) || silenced_account?
end
- def search_filtered?
- return false if !account.nil? && account.id == status.account_id
-
- blocked_by_policy_search? || (account_present? && filtered_status?) || silenced_account?
- end
-
private
def account_present?
@@ -59,15 +54,7 @@ class StatusFilter
!policy_allows_show?
end
- def blocked_by_policy_search?
- !policy_allows_search?
- end
-
def policy_allows_show?
- StatusPolicy.new(account, status, @preloaded_relations).show?
- end
-
- def policy_allows_search?
- StatusPolicy.new(account, status, @preloaded_relations).search?
+ StatusPolicy.new(account, status, @preloaded_relations, @preloaded_status_relations).show?
end
end
diff --git a/app/models/account.rb b/app/models/account.rb
index 2452a1a645..75f1939248 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -51,6 +51,7 @@
# reviewed_at :datetime
# requested_review_at :datetime
# group_allow_private_message :boolean
+# searchability :integer default("private"), not null
#
class Account < ApplicationRecord
@@ -82,6 +83,7 @@ class Account < ApplicationRecord
enum protocol: { ostatus: 0, activitypub: 1 }
enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
+ enum searchability: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10 }, _suffix: :searchability
validates :username, presence: true
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 48ab1349dd..a017b5c613 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -237,6 +237,10 @@ module AccountInteractions
status.proper.favourites.where(account: self).exists?
end
+ def emoji_reactioned?(status)
+ status.proper.emoji_reactions.where(account: self).exists?
+ end
+
def bookmarked?(status)
status.proper.bookmarks.where(account: self).exists?
end
diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb
index b3fa1f683b..7b448523eb 100644
--- a/app/models/concerns/has_user_settings.rb
+++ b/app/models/concerns/has_user_settings.rb
@@ -107,6 +107,10 @@ module HasUserSettings
settings['default_privacy'] || (account.locked? ? 'private' : 'public')
end
+ def setting_default_searchability
+ settings['default_searchability'] || 'private'
+ end
+
def allows_report_emails?
settings['notification_emails.report']
end
diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb
index 51a9058a84..6609c193e1 100644
--- a/app/models/public_feed.rb
+++ b/app/models/public_feed.rb
@@ -29,6 +29,7 @@ class PublicFeed
scope.merge!(account_filters_scope) if account?
scope.merge!(media_only_scope) if media_only?
scope.merge!(language_scope) if account&.chosen_languages.present?
+ scope.merge!(public_searchable_scope) if local_only? && !account?
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
end
@@ -97,6 +98,10 @@ class PublicFeed
Status.where(language: account.chosen_languages)
end
+ def public_searchable_scope
+ Status.where(searchability: 'public').or(Status.where(searchability: nil).merge(Account.where(searchability: 'public')))
+ end
+
def account_filters_scope
Status.not_excluded_by_account(account).tap do |scope|
scope.merge!(Status.not_domain_blocked_by_account(account)) unless local_only?
diff --git a/app/models/status.rb b/app/models/status.rb
index ebd4cac382..969ceb4191 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -27,6 +27,7 @@
# edited_at :datetime
# trendable :boolean
# ordered_media_attachment_ids :bigint(8) is an Array
+# searchability :integer
#
require 'ostruct'
@@ -52,6 +53,7 @@ class Status < ApplicationRecord
update_index('statuses', :proper)
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10 }, _suffix: :visibility
+ enum searchability: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10 }, _suffix: :searchability
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
@@ -115,6 +117,7 @@ class Status < ApplicationRecord
scope :tagged_with_none, lambda { |tag_ids|
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
}
+ scope :unset_searchability, -> { where(searchability: nil, reblog_of_id: nil) }
after_create_commit :trigger_create_webhooks
after_update_commit :trigger_update_webhooks
@@ -252,6 +255,11 @@ class Status < ApplicationRecord
ordered_media_attachments.any?
end
+ def expired?
+ false
+ # !expired_at.nil?
+ end
+
def with_preview_card?
preview_cards.any?
end
@@ -346,6 +354,15 @@ class Status < ApplicationRecord
attributes['trendable'].nil? && account.requires_review_notification?
end
+ def compute_searchability
+ # Fedibird code
+ #searchability || Status.searchabilities.invert.fetch([Account.searchabilities[account.searchability], Status.visibilities[visibility] || 0].max, nil) || 'direct'
+ # Reactions only (generic: direct)
+ return searchability if searchability
+ return account.searchability if account.local? && account.searchability
+ 'private'
+ end
+
after_create_commit :increment_counter_caches
after_destroy_commit :decrement_counter_caches
@@ -355,6 +372,7 @@ class Status < ApplicationRecord
before_validation :prepare_contents, if: :local?
before_validation :set_reblog
before_validation :set_visibility
+ before_validation :set_searchability
before_validation :set_conversation
before_validation :set_local
@@ -367,6 +385,10 @@ class Status < ApplicationRecord
visibilities.keys - %w(direct limited)
end
+ def selectable_searchabilities
+ searchabilities.keys - %w(public_unlisted limited)
+ end
+
def favourites_map(status_ids, account_id)
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
end
@@ -535,6 +557,12 @@ class Status < ApplicationRecord
self.sensitive = false if sensitive.nil?
end
+ def set_searchability
+ return if searchability.nil?
+
+ self.searchability = [Status.searchabilities[searchability], Status.visibilities[visibility == 'public_unlisted' ? 'public' : visibility]].max
+ end
+
def set_conversation
self.thread = thread.reblog if thread&.reblog?
diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb
index 2c025d6c56..cf5201118a 100644
--- a/app/models/user_settings.rb
+++ b/app/models/user_settings.rb
@@ -15,6 +15,7 @@ class UserSettings
setting :default_language, default: nil
setting :default_sensitive, default: false
setting :default_privacy, default: nil
+ setting :default_searchability, default: :private,
namespace :web do
setting :crop_images, default: true
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 74709647a8..a33d1f1112 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -1,12 +1,15 @@
# frozen_string_literal: true
class StatusPolicy < ApplicationPolicy
- def initialize(current_account, record, preloaded_relations = {})
+ def initialize(current_account, record, preloaded_relations = {}, preloaded_status_relations = {})
super(current_account, record)
@preloaded_relations = preloaded_relations
+ @preloaded_status_relations = preloaded_status_relations
end
+ delegate :reply?, :expired?, to: :record
+
def show?
return false if author.suspended?
@@ -19,18 +22,6 @@ class StatusPolicy < ApplicationPolicy
end
end
- def search?
- return false if author.suspended?
-
- if requires_mention?
- owned? || mention_exists?
- elsif !public?
- owned? || following_author? || mention_exists?
- else
- current_account.nil? || (!author_blocking? && !author_blocking_domain?)
- end
- end
-
def reblog?
!requires_mention? && (!private? || owned?) && show? && !blocking_author?
end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index b87cff51e1..492f658030 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -2,7 +2,7 @@
class InitialStatePresenter < ActiveModelSerializers::Model
attributes :settings, :push_subscription, :token,
- :current_account, :admin, :owner, :text, :visibility,
+ :current_account, :admin, :owner, :text, :visibility, :searchability,
:disabled_account, :moved_to_account
def role
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index e6dd8040e9..0a045050af 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -7,13 +7,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context :security
context_extensions :manually_approves_followers, :featured, :also_known_as,
- :moved_to, :property_value, :discoverable, :olm, :suspended
+ :moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by
attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary,
:url, :manually_approves_followers,
- :discoverable, :published
+ :discoverable, :published, :searchable_by
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
@@ -162,6 +162,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
object.created_at.midnight.iso8601
end
+ def searchable_by
+ ActivityPub::TagManager.instance.account_searchable_by(object)
+ end
+
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 27e058199d..e07eff2bf3 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,13 +3,13 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer
include FormattingHelper
- context_extensions :atom_uri, :conversation, :sensitive, :voters_count
+ context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :searchable_by
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
:attributed_to, :to, :cc, :sensitive,
:atom_uri, :in_reply_to_atom_uri,
- :conversation
+ :conversation, :searchable_by
attribute :content
attribute :content_map, if: :language?
@@ -138,6 +138,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
+ def searchable_by
+ ActivityPub::TagManager.instance.searchable_by(object)
+ end
+
def local?
object.account.local?
end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 769ba653ed..9767d3a5ac 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -69,10 +69,11 @@ class InitialStateSerializer < ActiveModel::Serializer
store = {}
if object.current_account
- store[:me] = object.current_account.id.to_s
- store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy
- store[:default_sensitive] = object.current_account.user.setting_default_sensitive
- store[:default_language] = object.current_account.user.preferred_posting_language
+ store[:me] = object.current_account.id.to_s
+ store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy
+ store[:default_searchability] = object.searchability || object.current_account.user.setting_default_searchability
+ store[:default_sensitive] = object.current_account.user.setting_default_sensitive
+ store[:default_language] = object.current_account.user.preferred_posting_language
end
store[:text] = object.text if object.text
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 2845470be0..bfbb484171 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
include FormattingHelper
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
- :note, :url, :avatar, :avatar_static, :header, :header_static,
+ :note, :url, :avatar, :avatar_static, :header, :header_static, :searchability,
:followers_count, :following_count, :statuses_count, :last_status_at
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index eb98750605..15b4cd425b 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -100,6 +100,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:visibility_public_unlisted,
:enable_wide_emoji,
:enable_wide_emoji_reaction,
+ :searchability,
]
capabilities << :profile_search unless Chewy.enabled?
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 382c8bb460..4094efa81e 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -5,7 +5,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :language,
- :uri, :url, :replies_count, :reblogs_count,
+ :uri, :url, :replies_count, :reblogs_count, :searchability,
:favourites_count, :emoji_reactions, :edited_at
attribute :favourited, if: :current_user?
@@ -61,6 +61,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
+ def searchability
+ object.compute_searchability
+ end
+
def sensitive
if current_user? && current_user.account_id == object.account_id
object.sensitive
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 603e4cf48b..46813efc62 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -29,6 +29,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account ||= Account.find_remote(@username, @domain)
@old_public_key = @account&.public_key
@old_protocol = @account&.protocol
+ @old_searchability = @account&.searchability
@suspension_changed = false
if @account.nil?
@@ -112,6 +113,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.fields = property_values || {}
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
@account.discoverable = @json['discoverable'] || false
+ @account.searchability = searchability_from_audience
end
def set_fetchable_key!
@@ -150,6 +152,10 @@ class ActivityPub::ProcessAccountService < BaseService
end
end
+ def after_searchability_change!
+ SearchabilityUpdateWorker.perform_async(@account.id) if @account.statuses.unset_searchability.exists?
+ end
+
def after_protocol_change!
ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
end
@@ -224,6 +230,24 @@ class ActivityPub::ProcessAccountService < BaseService
end
end
+ def audience_searchable_by
+ return nil if @json['searchableBy'].nil?
+
+ @audience_searchable_by_processaccountservice = as_array(@json['searchableBy']).map { |x| value_or_id(x) }
+ end
+
+ def searchability_from_audience
+ if audience_searchable_by.nil?
+ :private
+ elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) }
+ :public
+ elsif audience_searchable_by.include?(@account.followers_url)
+ :unlisted # Followers only in kmyblue (generics: private)
+ else
+ :private # Reaction only in kmyblue (generics: direct)
+ end
+ end
+
def property_values
return unless @json['attachment'].is_a?(Array)
@@ -310,6 +334,10 @@ class ActivityPub::ProcessAccountService < BaseService
!@old_protocol.nil? && @old_protocol != @account.protocol
end
+ def searchability_changed?
+ !@old_searchability.nil? && @old_searchability != @account.searchability
+ end
+
def process_tags
return if @json['tag'].blank?
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 73d92752b1..1bf094116f 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -22,6 +22,7 @@ class PostStatusService < BaseService
# @option [Status] :thread Optional status to reply to
# @option [Boolean] :sensitive
# @option [String] :visibility
+ # @option [String] :searchability
# @option [String] :spoiler_text
# @option [String] :language
# @option [String] :scheduled_at
@@ -66,12 +67,28 @@ class PostStatusService < BaseService
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
@visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted) && @account.silenced?
+ @searchability= searchability
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
rescue ArgumentError
raise ActiveRecord::RecordInvalid
end
+ def searchability
+ case @options[:searchability]&.to_sym
+ when :public
+ case @visibility&.to_sym when :public, :public_unlisted then :public when :unlisted then :unlisted when :private then :private else :direct end
+ when :unlisted
+ case @visibility&.to_sym when :public, :public_unlisted, :unlisted then :unlisted when :private then :private else :direct end
+ when :private
+ case @visibility&.to_sym when :public, :public_unlisted, :unlisted, :private then :private else :direct end
+ when nil
+ @account.searchability
+ else
+ :direct
+ end
+ end
+
def process_status!
@status = @account.statuses.new(status_attributes)
process_mentions_service.call(@status, save_records: false)
@@ -196,6 +213,7 @@ class PostStatusService < BaseService
sensitive: @sensitive,
spoiler_text: @options[:spoiler_text] || '',
visibility: @visibility,
+ searchability: @searchability,
language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale),
application: @options[:application],
rate_limit: @options[:with_rate_limit],
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index da9faf7384..005c063f70 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -8,6 +8,7 @@ class SearchService < BaseService
@limit = limit.to_i
@offset = options[:type].blank? ? 0 : options[:offset].to_i
@resolve = options[:resolve] || false
+ @searchability = options[:searchability] || 'public'
default_results.tap do |results|
next if @query.blank? || @limit.zero?
@@ -35,11 +36,26 @@ class SearchService < BaseService
end
def perform_statuses_search!
- # definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id })).order(id: :desc)
- definition = parsed_query.apply(StatusesIndex).order(id: :desc)
+ privacy_definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id }))
+ # 'private' searchability posts are NOT in here because it's already added at previous line.
+ case @searchability
+ when 'public'
+ privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'public' }))
+ privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'unlisted' }).filter(terms: { account_id: following_account_ids })) unless following_account_ids.empty?
+ privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'direct' }).filter(term: { account_id: @account.id }))
+ when 'unlisted', 'private'
+ privacy_definition = privacy_definition.or(StatusesIndex.filter(terms: { searchability: %w(public unlisted) }).filter(terms: { account_id: following_account_ids })) unless following_account_ids.empty?
+ privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'direct' }).filter(term: { account_id: @account.id }))
+ when 'direct'
+ privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'direct' }).filter(term: { account_id: @account.id }))
+ end
+
+ definition = parsed_query.apply(StatusesIndex).order(id: :desc)
definition = definition.filter(term: { account_id: @options[:account_id] }) if @options[:account_id].present?
+ definition = definition.and(privacy_definition)
+
if @options[:min_id].present? || @options[:max_id].present?
range = {}
range[:gt] = @options[:min_id].to_i if @options[:min_id].present?
@@ -50,9 +66,9 @@ class SearchService < BaseService
results = definition.limit(@limit).offset(@offset).objects.compact
account_ids = results.map(&:account_id)
account_domains = results.map(&:account_domain)
- preloaded_relations = @account.relations_map(account_ids, account_domains)
+ account_relations = @account.relations_map(account_ids, account_domains) # variable old name: preloaded_relations
- results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).search_filtered? }
+ results.reject { |status| StatusFilter.new(status, @account, account_relations).filtered? }
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
[]
end
@@ -115,4 +131,13 @@ class SearchService < BaseService
def parsed_query
SearchQueryTransformer.new.apply(SearchQueryParser.new.parse(@query))
end
+
+ def following_account_ids
+ return @following_account_ids if defined?(@following_account_ids)
+
+ account_exists_sql = Account.where('accounts.id = follows.target_account_id').where(searchability: %w(public unlisted private)).reorder(nil).select(1).to_sql
+ status_exists_sql = Status.where('statuses.account_id = follows.target_account_id').where(reblog_of_id: nil).where(searchability: %w(public unlisted private)).reorder(nil).select(1).to_sql
+ following_accounts = Follow.where(account_id: @account.id).merge(Account.where("EXISTS (#{account_exists_sql})").or(Account.where("EXISTS (#{status_exists_sql})")))
+ @following_account_ids = following_accounts.pluck(:target_account_id)
+ end
end
diff --git a/app/services/searchability_update_service.rb b/app/services/searchability_update_service.rb
new file mode 100644
index 0000000000..7ced32f22c
--- /dev/null
+++ b/app/services/searchability_update_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class SearchabilityUpdateService < BaseService
+ def call(account)
+ statuses = account.statuses.unset_searchability
+
+ return unless statuses.exists?
+
+ ids = statuses.pluck(:id)
+
+ if account.public_searchability?
+ statuses.update_all('searchability = CASE visibility WHEN 0 THEN 0 WHEN 10 THEN 0 WHEN 1 THEN 2 WHEN 2 THEN 2 ELSE 3 END, updated_at = CURRENT_TIMESTAMP')
+ elsif account.unlisted_searchability?
+ statuses.update_all('searchability = CASE visibility WHEN 0 THEN 1 WHEN 10 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 2 ELSE 3 END, updated_at = CURRENT_TIMESTAMP')
+ elsif account.private_searchability?
+ statuses.update_all('searchability = CASE WHEN visibility IN (0, 1, 2, 10) THEN 2 ELSE 3 END, updated_at = CURRENT_TIMESTAMP')
+ else
+ statuses.update_all('searchability = 3, updated_at = CURRENT_TIMESTAMP')
+ end
+
+ return unless Chewy.enabled?
+
+ ids.each_slice(100) do |chunk_ids|
+ StatusesIndex.import chunk_ids, update_fields: [:searchability]
+ end
+ end
+end
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 6590ec7c21..67946c62fa 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -23,6 +23,9 @@
.fields-group.fields-row__column.fields-row__column-6
= ff.input :default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false, label: I18n.t('simple_form.labels.defaults.setting_default_language')
+ .fields-group
+ = f.input :setting_default_searchability, collection: Status.selectable_searchabilities, wrapper: :with_label, include_blank: false, label_method: lambda { |searchability| safe_join([I18n.t("statuses.searchabilities.#{searchability}"), I18n.t("statuses.searchabilities.#{searchability}_long")], ' - ') }, required: false, hint: false
+
.fields-group
= ff.input :default_sensitive, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_sensitive'), hint: I18n.t('simple_form.hints.defaults.setting_default_sensitive')
diff --git a/app/workers/searchability_update_worker.rb b/app/workers/searchability_update_worker.rb
new file mode 100644
index 0000000000..ce288f813a
--- /dev/null
+++ b/app/workers/searchability_update_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class SearchabilityUpdateWorker
+ include Sidekiq::Worker
+
+ sidekiq_options queue: 'pull', lock: :until_executed
+
+ def perform(account_id)
+ SearchabilityUpdateService.new.call(Account.find(account_id))
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index d71ac227db..233926c680 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -795,6 +795,7 @@ en:
remove: Remove post
remove_media: Remove medias
remove_history: Remove edit history
+ searchability: Searchability
status_changed: Post changed
title: Account posts
trending: Trending
@@ -1562,6 +1563,15 @@ en:
one: "%{count} vote"
other: "%{count} votes"
vote: Vote
+ searchabilities:
+ direct: Self only
+ direct_long: Nobody can find, but you can
+ private: Reactionners
+ private_long: Reacter of this post can find
+ public: Public
+ public_long: Anyone can find
+ unlisted: Followers only
+ unlisted_long: Your followers can find
show_more: Show more
show_newer: Show newer
show_older: Show older
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 4b2d19c01f..c9c1016fdc 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -781,6 +781,7 @@ ja:
remove: 投稿を削除
remove_media: メディアを削除
remove_history: 編集履歴を削除
+ searchability: 検索許可
status_changed: 投稿を変更しました
title: 投稿一覧
trending: トレンド
@@ -1529,6 +1530,15 @@ ja:
total_votes:
other: "%{count}票"
vote: 投票
+ searchabilities:
+ direct: 自分のみ
+ direct_long: この投稿はあなたしか検索できません
+ private: リアクションした人
+ private_long: この投稿にリアクションした人しか検索できません
+ public: 公開
+ public_long: この投稿は誰でも検索できます
+ unlisted: フォロワーのみ
+ unlisted_long: この投稿はフォロワーのみが検索できます
show_more: もっと見る
show_newer: 新しいものを表示
show_older: 古いものを表示
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 96978578bb..00aa828ba2 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -206,6 +206,7 @@ en:
setting_crop_images: Crop images in non-expanded posts to 16x9
setting_default_language: Posting language
setting_default_privacy: Posting privacy
+ setting_default_searchability: Searchability
setting_default_sensitive: Always mark media as sensitive
setting_delete_modal: Show confirmation dialog before deleting a post
setting_disable_swiping: Disable swiping motions
diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml
index 8fc8dd265e..3ffa286218 100644
--- a/config/locales/simple_form.ja.yml
+++ b/config/locales/simple_form.ja.yml
@@ -206,6 +206,7 @@ ja:
setting_crop_images: 投稿の詳細以外では画像を16:9に切り抜く
setting_default_language: 投稿する言語
setting_default_privacy: 投稿の公開範囲
+ setting_default_searchability: 投稿の検索を許可する範囲
setting_default_sensitive: メディアを常に閲覧注意としてマークする
setting_delete_modal: 投稿を削除する前に確認ダイアログを表示する
setting_disable_swiping: スワイプでの切り替えを無効にする
diff --git a/db/migrate/20230405121613_add_searchability_to_statuses.rb b/db/migrate/20230405121613_add_searchability_to_statuses.rb
new file mode 100644
index 0000000000..313509afb8
--- /dev/null
+++ b/db/migrate/20230405121613_add_searchability_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddSearchabilityToStatuses < ActiveRecord::Migration[6.1]
+ def change
+ add_column :statuses, :searchability, :integer
+ end
+end
diff --git a/db/migrate/20230405121625_add_searchability_to_accounts.rb b/db/migrate/20230405121625_add_searchability_to_accounts.rb
new file mode 100644
index 0000000000..758d052993
--- /dev/null
+++ b/db/migrate/20230405121625_add_searchability_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddSearchabilityToAccounts < ActiveRecord::Migration[6.1]
+ def change
+ add_column :accounts, :searchability, :integer, null: false, default: 0
+ end
+end
diff --git a/db/migrate/20230406041523_change_searchability_default_value.rb b/db/migrate/20230406041523_change_searchability_default_value.rb
new file mode 100644
index 0000000000..b3ca38387c
--- /dev/null
+++ b/db/migrate/20230406041523_change_searchability_default_value.rb
@@ -0,0 +1,5 @@
+class ChangeSearchabilityDefaultValue < ActiveRecord::Migration[6.1]
+ def change
+ change_column_default :accounts, :searchability, from: 0, to: 2
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index cc7f1ef125..57541246c3 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.define(version: 2023_03_20_234918) do
+ActiveRecord::Schema.define(version: 2023_04_06_041523) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -188,6 +188,7 @@ ActiveRecord::Schema.define(version: 2023_03_20_234918) do
t.datetime "reviewed_at"
t.datetime "requested_review_at"
t.boolean "group_allow_private_message"
+ t.integer "searchability", default: 2, null: false
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id", where: "(moved_to_account_id IS NOT NULL)"
@@ -969,6 +970,7 @@ ActiveRecord::Schema.define(version: 2023_03_20_234918) do
t.datetime "edited_at"
t.boolean "trendable"
t.bigint "ordered_media_attachment_ids", array: true
+ t.integer "searchability"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["account_id"], name: "index_statuses_on_account_id"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"