From 91d6b018dfef47f19fcab13fb2d56f6e657f7db5 Mon Sep 17 00:00:00 2001 From: noellabo Date: Tue, 23 Aug 2022 14:14:23 +0900 Subject: [PATCH] Add searchability features --- Gemfile.lock | 34 ++- app/chewy/statuses_index.rb | 1 + .../api/v1/accounts/credentials_controller.rb | 2 +- app/controllers/api/v1/statuses_controller.rb | 4 +- app/controllers/api/v2/search_controller.rb | 2 +- .../settings/preferences_controller.rb | 1 + .../settings/profiles_controller.rb | 2 +- app/helpers/context_helper.rb | 1 + app/javascript/mastodon/actions/compose.js | 23 +- .../compose/components/compose_form.js | 2 + .../components/searchability_dropdown.js | 280 ++++++++++++++++++ .../searchability_dropdown_container.js | 23 ++ app/javascript/mastodon/locales/en.json | 7 + app/javascript/mastodon/locales/ja.json | 7 + app/javascript/mastodon/reducers/compose.js | 54 +++- app/javascript/mastodon/reducers/search.js | 15 + app/lib/activitypub/activity.rb | 34 +++ app/lib/activitypub/activity/create.rb | 2 + app/lib/activitypub/tag_manager.rb | 81 ++--- app/lib/user_settings_decorator.rb | 5 + app/models/account.rb | 2 + app/models/public_feed.rb | 8 +- app/models/status.rb | 18 ++ app/models/user.rb | 1 + .../activitypub/actor_serializer.rb | 10 +- .../activitypub/note_serializer.rb | 9 +- app/serializers/initial_state_serializer.rb | 15 +- app/serializers/rest/account_serializer.rb | 2 +- app/serializers/rest/instance_serializer.rb | 1 + app/serializers/rest/status_serializer.rb | 7 +- .../account_full_text_search_service.rb | 3 +- .../activitypub/process_account_service.rb | 29 ++ app/services/fan_out_on_write_service.rb | 5 + app/services/post_status_service.rb | 16 + app/services/search_service.rb | 35 ++- app/services/searchability_update_service.rb | 25 ++ .../settings/preferences/other/show.html.haml | 5 + app/views/settings/profiles/show.html.haml | 4 + app/workers/searchability_update_worker.rb | 13 + config/locales/en.yml | 16 + config/locales/ja.yml | 16 + config/locales/simple_form.en.yml | 4 + config/locales/simple_form.ja.yml | 4 + config/settings.yml | 1 + ...20817075513_add_searchability_to_status.rb | 13 + ...0817075643_add_searchability_to_account.rb | 17 ++ ...setting_to_default_search_searchability.rb | 11 + db/schema.rb | 4 + streaming/index.js | 110 ++++--- 49 files changed, 857 insertions(+), 127 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/searchability_dropdown.js create mode 100644 app/javascript/mastodon/features/compose/containers/searchability_dropdown_container.js create mode 100644 app/services/searchability_update_service.rb create mode 100644 app/workers/searchability_update_worker.rb create mode 100644 db/migrate/20220817075513_add_searchability_to_status.rb create mode 100644 db/migrate/20220817075643_add_searchability_to_account.rb create mode 100644 db/post_migrate/20220823185527_conservative_setting_to_default_search_searchability.rb diff --git a/Gemfile.lock b/Gemfile.lock index 23c43b094..b6d086e76 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -164,9 +164,9 @@ GEM activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (7.2.2) + chewy (7.2.6) activesupport (>= 5.2) - elasticsearch (>= 7.12.0) + elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl chunky_png (1.4.0) cld3 (3.4.2) @@ -214,13 +214,13 @@ GEM railties (>= 3.2) e2mmap (0.1.0) ed25519 (1.2.4) - elasticsearch (7.13.1) - elasticsearch-api (= 7.13.1) - elasticsearch-transport (= 7.13.1) - elasticsearch-api (7.13.1) + elasticsearch (7.13.3) + elasticsearch-api (= 7.13.3) + elasticsearch-transport (= 7.13.3) + elasticsearch-api (7.13.3) multi_json elasticsearch-dsl (0.1.10) - elasticsearch-transport (7.13.1) + elasticsearch-transport (7.13.3) faraday (~> 1) multi_json encryptor (3.0.0) @@ -231,23 +231,29 @@ GEM fabrication (2.22.0) faker (2.18.0) i18n (>= 1.6, < 2) - faraday (1.5.1) + faraday (1.10.2) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) + faraday-net_http_persistent (~> 1.0) faraday-patron (~> 1.0) - multipart-post (>= 1.2, < 3) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-em_http (1.0.0) faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) faraday-net_http (1.0.1) - faraday-net_http_persistent (1.1.0) + faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) fast_blank (1.0.0) fastimage (2.2.4) ffi (1.15.0) @@ -388,7 +394,7 @@ GEM minitest (5.14.4) msgpack (1.4.2) multi_json (1.15.0) - multipart-post (2.1.1) + multipart-post (2.2.3) net-ldap (0.17.0) net-scp (3.0.0) net-ssh (>= 2.6.5, < 7.0.0) @@ -569,7 +575,7 @@ GEM ruby-progressbar (1.11.0) ruby-saml (1.11.0) nokogiri (>= 1.5.10) - ruby2_keywords (0.0.4) + ruby2_keywords (0.0.5) rufus-scheduler (3.7.0) fugit (~> 1.1, >= 1.1.6) safety_net_attestation (0.4.0) diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index 4bf15b02d..60b1894e1 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -78,5 +78,6 @@ class StatusesIndex < Chewy::Index end field :searchable_by, type: 'long', value: ->(status, crutches) { status.searchable_by(crutches) } + field :searchability, type: 'keyword', value: ->(status) { status.compute_searchability } end end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 0bc9bbe6a..8566f0e7d 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -21,7 +21,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController private def account_params - params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, :birthday, :location, fields_attributes: [:name, :value]) + params.permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, :searchability, :birthday, :location, fields_attributes: [:name, :value]) end def user_settings_params diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 2eab611dd..0bfbac33a 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -70,7 +70,8 @@ class Api::V1::StatusesController < Api::BaseController with_rate_limit: true, quote_id: status_params[:quote_id].presence, status_reference_ids: (Array(status_params[:status_reference_ids]).uniq.map(&:to_i)), - status_reference_urls: status_params[:status_reference_urls] || [] + status_reference_urls: status_params[:status_reference_urls] || [], + searchability: status_params[:searchability] ) @@ -154,6 +155,7 @@ class Api::V1::StatusesController < Api::BaseController :expires_at, :expires_action, :with_reference, + :searchability, media_ids: [], poll: [ :multiple, diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index d08c9ff31..97aa8cd4d 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -25,6 +25,6 @@ class Api::V2::SearchController < Api::BaseController end def search_params - params.permit(:type, :offset, :min_id, :max_id, :account_id, :with_profiles) + params.permit(:type, :offset, :min_id, :max_id, :account_id, :with_profiles, :searchability) end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 06ead8605..d9a51a38c 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -93,6 +93,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_content_emoji_reaction_size, :setting_hide_bot_on_public_timeline, :setting_confirm_follow_from_bot, + :setting_default_search_searchability, notification_emails: %i(follow follow_request reblog favourite emoji_reaction status_reference mention digest report pending_account trending_tag), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 22c34bc77..666685f5d 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, :birthday, :location, fields_attributes: [:name, :value]) + params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, :searchability, :birthday, :location, fields_attributes: [:name, :value]) end def set_account diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 9a28d6212..375d412e2 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -29,6 +29,7 @@ module ContextHelper other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' }, references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => "fedibird:references", '@type' => '@id' } }, emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => "fedibird:emojiReactions", '@type' => '@id' } }, + searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => "fedibird:searchableBy", '@type' => '@id' } }, is_cat: { 'misskey' => 'https://misskey-hub.net/ns#', 'isCat' => 'misskey:isCat' }, vcard: { 'vcard' => 'http://www.w3.org/2006/vcard/ns#' }, }.freeze diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index b3d9cbd3f..40ec1d4d9 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -50,13 +50,14 @@ export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; -export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; -export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; -export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; -export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; -export const COMPOSE_CIRCLE_CHANGE = 'COMPOSE_CIRCLE_CHANGE'; -export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; -export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; +export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_SEARCHABILITY_CHANGE = 'COMPOSE_SEARCHABILITY_CHANGE'; +export const COMPOSE_CIRCLE_CHANGE = 'COMPOSE_CIRCLE_CHANGE'; +export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; @@ -277,6 +278,7 @@ export function submitCompose(routerHistory) { expires_in: expires_in, expires_action: expires_action, status_reference_ids: statusReferenceIds, + searchability: getState().getIn(['compose', 'searchability']), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), @@ -756,6 +758,13 @@ export function changeComposeVisibility(value) { }; }; +export function changeComposeSearchability(value) { + return { + type: COMPOSE_SEARCHABILITY_CHANGE, + value, + }; +}; + export function changeComposeCircle(value) { return { type: COMPOSE_CIRCLE_CHANGE, diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 66a5f390f..7bd3fe9b6 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -13,6 +13,7 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import SearchabilityDropdownContainer from '../containers/searchability_dropdown_container'; import CircleDropdownContainer from '../containers/circle_dropdown_container'; import DateTimeFormContainer from '../containers/datetime_form_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; @@ -265,6 +266,7 @@ class ComposeForm extends ImmutablePureComponent { +
diff --git a/app/javascript/mastodon/features/compose/components/searchability_dropdown.js b/app/javascript/mastodon/features/compose/components/searchability_dropdown.js new file mode 100644 index 000000000..a06ebc43e --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/searchability_dropdown.js @@ -0,0 +1,280 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from '../../ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import classNames from 'classnames'; +import Icon from 'mastodon/components/icon'; + +const messages = defineMessages({ + public_short: { id: 'searchability.public.short', defaultMessage: 'Public' }, + public_long: { id: 'searchability.public.long', defaultMessage: 'Searchable for all' }, + private_short: { id: 'searchability.private.short', defaultMessage: 'Followers-only' }, + private_long: { id: 'searchability.private.long', defaultMessage: 'Searchable for followers only' }, + direct_short: { id: 'searchability.direct.short', defaultMessage: 'Reacted-users-only' }, + direct_long: { id: 'searchability.direct.long', defaultMessage: 'Searchable for reacted users only' }, + change_searchability: { id: 'searchability.change', defaultMessage: 'Adjust status searchability' }, +}); + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +class SearchabilityDropdownMenu extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.string.isRequired, + placement: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + state = { + mounted: false, + }; + + handleDocumentClick = e => { + 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 }); + this.setState({ mounted: 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 { mounted } = this.state; + const { style, items, placement, value } = this.props; + + return ( + + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays +
+ {items.map(item => ( +
+
+ + +
+ +
+ {item.text} + {item.meta} +
+
+ ))} +
+ )} +
+ ); + } + +} + +export default @injectIntl +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, + intl: PropTypes.object.isRequired, + }; + + state = { + open: false, + placement: 'bottom', + }; + + handleToggle = ({ target }) => { + 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 { + const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus({ preventScroll: true }); + } + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + 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: '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) }, + ]; + } + + render () { + const { value, container, intl } = this.props; + const { open, placement } = this.state; + + const valueOption = this.options.find(item => item.value === value); + + return ( +
+
+ +
+ + + + +
+ ); + } + +} 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 000000000..3b4a83a1f --- /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/locales/en.json b/app/javascript/mastodon/locales/en.json index 398d6ea49..de6e68ed2 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -493,6 +493,13 @@ "report.submit": "Submit", "report.target": "Reporting {target}", "search.placeholder": "Search", + "searchability.change": "Adjust status searchability", + "searchability.direct.short": "Reacted-users-only (Search)", + "searchability.direct.long": "Searchable for reacted users only", + "searchability.private.short": "Followers-only (Search)", + "searchability.private.long": "Searchable for followers only", + "searchability.public.short": "Public (Search)", + "searchability.public.long": "Searchable for all", "search_popout.search_format": "Advanced search format", "search_popout.tips.full_text": "Simple text returns posts you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.hashtag": "hashtag", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index ab9c8027c..39542e064 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -493,6 +493,13 @@ "report.submit": "通報する", "report.target": "{target}さんを通報する", "search.placeholder": "検索", + "searchability.change": "検索範囲を変更", + "searchability.direct.short": "リアクション限定(検索)", + "searchability.direct.long": "リアクションしたユーザーだけが検索可", + "searchability.private.short": "フォロワー限定(検索)", + "searchability.private.long": "フォロワーだけが検索可", + "searchability.public.short": "公開(検索)", + "searchability.public.long": "誰でも検索可", "search_popout.search_format": "高度な検索フォーマット", "search_popout.tips.full_text": "表示名やユーザー名、ハッシュタグのほか、あなたの投稿やお気に入り、ブーストした投稿、返信に一致する単純なテキスト。", "search_popout.tips.hashtag": "ハッシュタグ", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 9179c83c0..eecc1b333 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -29,6 +29,7 @@ import { COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_VISIBILITY_CHANGE, + COMPOSE_SEARCHABILITY_CHANGE, COMPOSE_CIRCLE_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, @@ -69,6 +70,7 @@ const initialState = ImmutableMap({ spoiler: false, spoiler_text: '', privacy: null, + searchability: null, circle_id: null, text: '', focusDate: null, @@ -92,6 +94,7 @@ const initialState = ImmutableMap({ suggestions: ImmutableList(), default_privacy: 'public', default_sensitive: false, + default_searchability: 'private', resetFileKey: Math.floor((Math.random() * 0x10000)), idempotencyKey: null, tagHistory: ImmutableList(), @@ -151,6 +154,7 @@ const clearAll = state => { map.set('quote_from', null); map.set('reply_status', null); map.set('privacy', state.get('default_privacy')); + map.set('searchability', state.get('default_searchability')); map.set('circle_id', null); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); @@ -239,11 +243,22 @@ const insertEmoji = (state, position, emojiData, needsSpace) => { }); }; -const privacyPreference = (a, b) => { +const privacyExpand = (a, b) => { + const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct']; + return order[Math.min(order.indexOf(a), order.indexOf(b), order.length - 1)]; +}; + +const privacyCap = (a, b) => { const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct']; return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; }; +const searchabilityCap = (a, b) => { + const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct']; + const to = ['public', 'private', 'private', 'direct', 'direct', 'direct']; + return to[Math.max(order.indexOf(a), order.indexOf(b), 0)]; +}; + const hydrate = (state, hydratedState) => { state = clearAll(state.merge(hydratedState)); @@ -347,11 +362,27 @@ export default function compose(state = initialState, action) { .set('idempotencyKey', uuid()); case COMPOSE_VISIBILITY_CHANGE: return state.withMutations(map => { + const searchability = searchabilityCap(action.value, state.get('searchability')); + map.set('text', statusToTextMentions(state.get('text'), action.value, state.get('reply_status'))); map.set('privacy', action.value); + map.set('searchability', searchability); map.set('idempotencyKey', uuid()); map.set('circle_id', null); }); + case COMPOSE_SEARCHABILITY_CHANGE: + return state.withMutations(map => { + map.set('searchability', action.value); + map.set('idempotencyKey', uuid()); + + const privacy = privacyExpand(action.value, state.get('privacy')); + + if (privacy !== state.get('privacy')) { + map.set('text', statusToTextMentions(state.get('text'), action.value, state.get('reply_status'))); + map.set('privacy', privacy); + map.set('circle_id', null); + } + }); case COMPOSE_CIRCLE_CHANGE: return state .set('circle_id', action.value) @@ -363,15 +394,17 @@ export default function compose(state = initialState, action) { case COMPOSE_COMPOSING_CHANGE: return state.set('is_composing', action.value); case COMPOSE_REPLY: - const privacy = privacyPreference(action.status.get('visibility'), state.get('default_privacy')); - return state.withMutations(map => { + const privacy = privacyCap(action.status.get('visibility'), state.get('default_privacy')); + const searchability = searchabilityCap(action.status.get('visibility'), state.get('default_searchability')); + map.set('in_reply_to', action.status.get('id')); map.set('quote_from', null); map.set('quote_from_url', null); map.set('reply_status', action.status); map.set('text', statusToTextMentions('', privacy, action.status)); map.set('privacy', privacy); + map.set('searchability', searchability); map.set('circle_id', null); map.set('focusDate', new Date()); map.set('caretPosition', null); @@ -382,7 +415,7 @@ export default function compose(state = initialState, action) { map.set('expires', null); map.set('expires_action', 'mark'); map.update('context_references', set => set.clear().concat(action.context_references)); - + if (action.status.get('spoiler_text').length > 0) { map.set('spoiler', true); map.set('spoiler_text', action.status.get('spoiler_text')); @@ -393,11 +426,15 @@ export default function compose(state = initialState, action) { }); case COMPOSE_QUOTE: return state.withMutations(map => { + const privacy = privacyCap(action.status.get('visibility'), state.get('default_privacy')); + const searchability = searchabilityCap(action.status.get('visibility'), state.get('default_searchability')); + map.set('in_reply_to', null); map.set('quote_from', action.status.get('id')); map.set('quote_from_url', action.status.get('url')); map.set('text', ''); - map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('privacy', privacy); + map.set('searchability', searchability); map.set('focusDate', new Date()); map.set('preselectDate', new Date()); map.set('idempotencyKey', uuid()); @@ -406,7 +443,7 @@ export default function compose(state = initialState, action) { map.set('expires', null); map.set('expires_action', 'mark'); map.update('context_references', set => set.clear().add(action.status.get('id'))); - + if (action.status.get('spoiler_text').length > 0) { map.set('spoiler', true); map.set('spoiler_text', action.status.get('spoiler_text')); @@ -427,6 +464,7 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('searchability', state.get('default_searchability')); map.set('circle_id', null); map.set('poll', null); map.set('idempotencyKey', uuid()); @@ -499,6 +537,7 @@ export default function compose(state = initialState, action) { return state.withMutations(map => { map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); map.set('privacy', 'direct'); + map.set('searchability', 'direct'); map.set('circle_id', null); map.set('focusDate', new Date()); map.set('caretPosition', null); @@ -542,6 +581,7 @@ export default function compose(state = initialState, action) { map.set('quote_from_url', action.status.getIn(['quote', 'url'])); map.set('reply_status', action.replyStatus); map.set('privacy', action.status.get('visibility')); + map.set('searchability', action.status.get('searchability')); map.set('circle_id', action.status.get('circle_id')); map.set('media_attachments', action.status.get('media_attachments')); map.set('focusDate', new Date()); @@ -554,7 +594,7 @@ export default function compose(state = initialState, action) { map.set('expires_action', action.status.get('expires_action') ?? 'mark'); map.update('references', set => set.clear().concat(action.status.get('status_reference_ids'))); map.update('context_references', set => set.clear().concat(action.context_references)); - + if (action.status.get('spoiler_text').length > 0) { map.set('spoiler', true); map.set('spoiler_text', action.status.get('spoiler_text')); diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js index 90e3ee37e..a2dbb53ee 100644 --- a/app/javascript/mastodon/reducers/search.js +++ b/app/javascript/mastodon/reducers/search.js @@ -1,3 +1,4 @@ +import { STORE_HYDRATE } from '../actions/store'; import { SEARCH_CHANGE, SEARCH_CLEAR, @@ -19,10 +20,23 @@ const initialState = ImmutableMap({ hidden: false, results: ImmutableMap(), searchTerm: '', + searchability: 'private', + default_searchability: 'private', }); export default function search(state = initialState, action) { switch(action.type) { + case STORE_HYDRATE: + const default_searchability = action.state.getIn(['search', 'default_searchability']); + + if (default_searchability) { + return state.withMutations(map => { + map.set('searchability', default_searchability); + map.set('default_searchability', default_searchability); + }); + } + + return state; case SEARCH_CHANGE: return state.set('value', action.value); case SEARCH_CLEAR: @@ -31,6 +45,7 @@ export default function search(state = initialState, action) { map.set('results', ImmutableMap()); map.set('submitted', false); map.set('hidden', false); + map.set('searchability', state.get('default_searchability')); }); case SEARCH_SHOW: return state.set('hidden', false); diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 7f1163060..9605aad5f 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -263,6 +263,12 @@ class ActivityPub::Activity as_array(@json['cc']).map { |x| value_or_id(x) } end + def audience_searchable_by + return nil if @object['searchableBy'].nil? + + as_array(@object['searchableBy']).map { |x| value_or_id(x) } + end + def process_audience conversation_uri = value_or_id(@object['context']) @@ -334,4 +340,32 @@ class ActivityPub::Activity uri = ActivityPub::TagManager.instance.uri_for(account) audience_to.include?(uri) || audience_cc.include?(uri) 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) + :private + else + :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, :private].include?(searchability) && [:public, :unlisted].include?(visibility) + :private + else + :direct + end + end end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 10f0dfa60..97950e500 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -87,6 +87,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity resolve_references(@status, @mentions, @object['references']) resolve_thread(@status) fetch_replies(@status) + StatusesIndex.import @status if Chewy.enabled? distribute(@status) forward_for_conversation forward_for_reply @@ -113,6 +114,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity reply: @object['inReplyTo'].present?, sensitive: @account.sensitized? || @object['sensitive'] || false, visibility: visibility_from_audience_with_silence, + searchability: searchability, thread: replied_to_status, conversation: conversation_from_context, media_attachment_ids: process_attachments.take(4).map(&:id), diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 72f308db7..0999655cd 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -99,23 +99,7 @@ class ActivityPub::TagManager when 'limited' status.conversation_id.present? ? [uri_for(status.conversation)] : [] when 'direct' - if status.account.silenced? - # Only notify followers if the account is locally silenced - account_ids = status.active_mentions.pluck(:account_id) - to = 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 - to.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 + mentions_uris(status) end end @@ -136,27 +120,54 @@ class ActivityPub::TagManager cc << COLLECTIONS[:public] end - unless status.direct_visibility? - if status.account.silenced? - # Only notify followers if the account is locally silenced - account_ids = status.active_mentions.pluck(:account_id) - cc.concat(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) - cc.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) + cc.concat(mentions_uris(status)) unless status.direct_visibility? + end + + def searchable_by(status) + searchable_by = + case status.compute_searchability + when 'public' + [COLLECTIONS[:public]] + when 'unlisted', 'private' + [account_followers_url(status.account)] + when 'limited' + status.conversation_id.present? ? [uri_for(status.conversation)] : [] else - cc.concat(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 + + 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 - - cc end def local_uri?(uri) diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index e38384dc5..007db1137 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -87,6 +87,7 @@ class UserSettingsDecorator user.settings['content_emoji_reaction_size'] = content_emoji_reaction_size_preference if change?('setting_content_emoji_reaction_size') user.settings['hide_bot_on_public_timeline'] = hide_bot_on_public_timeline_preference if change?('setting_hide_bot_on_public_timeline') user.settings['confirm_follow_from_bot'] = confirm_follow_from_bot_preference if change?('setting_confirm_follow_from_bot') + user.settings['default_search_searchability'] = default_search_searchability_preference if change?('setting_default_search_searchability') end def merged_notification_emails @@ -321,6 +322,10 @@ end boolean_cast_setting 'setting_confirm_follow_from_bot' end + def default_search_searchability_preference + settings['setting_default_search_searchability'] + end + def boolean_cast_setting(key) ActiveModel::Type::Boolean.new.cast(settings[key]) end diff --git a/app/models/account.rb b/app/models/account.rb index e2e64b9c8..fd5ce9eb8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -49,6 +49,7 @@ # sensitized_at :datetime # settings :jsonb default("{}"), not null # silence_mode :integer default(0), not null +# searchability :integer default(3), not null # class Account < ApplicationRecord @@ -85,6 +86,7 @@ class Account < ApplicationRecord enum protocol: [:ostatus, :activitypub] enum suspension_origin: [:local, :remote], _prefix: true enum silence_mode: { soft: 0, hard: 1 }, _suffix: :silence_mode + enum searchability: [:public, :unlisted, :private, :direct, :limited, :mutual], _suffix: :searchability validates :username, presence: true validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? } diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index 8b1f4a38e..154a35f27 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -21,12 +21,14 @@ class PublicFeed # @param [Integer] min_id # @return [Array] def get(limit, max_id = nil, since_id = nil, min_id = nil) - return Status.none if local_only? && !imast? && !mastodon_for_ios? && !mastodon_for_android? + return Status.none if local_only? && account? && !imast? && !mastodon_for_ios? && !mastodon_for_android? scope = public_scope scope.merge!(without_replies_scope) unless with_replies? scope.merge!(without_reblogs_scope) unless with_reblogs? + scope.merge!(local_only_scope) if local_only? && !account? + scope.merge!(public_searchable_scope) if local_only? && !account? scope.merge!(remote_only_scope) if remote_only? scope.merge!(domain_only_scope) if domain_only? scope.merge!(account_filters_scope) if account? @@ -101,6 +103,10 @@ class PublicFeed Status.local end + def public_searchable_scope + Status.where(searchability: 'public').or(Status.where(searchability: nil).merge(Account.where(searchability: 'public'))) + end + def remote_only_scope Status.remote end diff --git a/app/models/status.rb b/app/models/status.rb index 79e47022b..b8f2247a7 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -25,6 +25,7 @@ # deleted_at :datetime # quote_id :bigint(8) # expired_at :datetime +# searchability :integer # class Status < ApplicationRecord @@ -51,6 +52,7 @@ class Status < ApplicationRecord update_index('statuses', :proper) enum visibility: [:public, :unlisted, :private, :direct, :limited, :mutual], _suffix: :visibility + enum searchability: [:public, :unlisted, :private, :direct, :limited, :mutual], _suffix: :searchability belongs_to :application, class_name: 'Doorkeeper::Application', optional: true @@ -130,6 +132,7 @@ class Status < ApplicationRecord .where("t#{id}.tag_id IS NULL") end } + scope :unset_searchability, -> { where(searchability: nil, reblog_of_id: nil) } cache_associated :application, :media_attachments, @@ -185,6 +188,10 @@ class Status < ApplicationRecord ids.uniq end + def compute_searchability + searchability || Status.searchabilities.invert.fetch([Account.searchabilities[account.searchability], Status.visibilities[visibility] || 0].max, nil) || 'direct' + end + def reply? !in_reply_to_id.nil? || attributes['reply'] end @@ -382,6 +389,7 @@ class Status < ApplicationRecord before_validation :prepare_contents, on: :create, if: :local? before_validation :set_reblog, on: :create before_validation :set_visibility, on: :create + before_validation :set_searchability, on: :create before_validation :set_conversation, on: :create before_validation :set_local, on: :create @@ -393,6 +401,10 @@ class Status < ApplicationRecord visibilities.keys - %w(direct limited) end + def selectable_searchabilities + searchabilities.keys - %w(unlisted limited mutual) + 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 @@ -556,6 +568,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]].max + end + def set_conversation self.thread = thread.reblog if thread&.reblog? diff --git a/app/models/user.rb b/app/models/user.rb index 3276a9f87..8f045d427 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -141,6 +141,7 @@ class User < ApplicationRecord :multi_column_customize, :multi_column_content_font_size, :multi_column_info_font_size, :multi_column_content_emoji_reaction_size, :mobile_customize, :mobile_content_font_size, :mobile_info_font_size, :mobile_content_emoji_reaction_size, :hide_bot_on_public_timeline, :confirm_follow_from_bot, + :default_search_searchability, to: :settings, prefix: :setting, allow_nil: false diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 215afb5c8..fed9b5d07 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -8,13 +8,15 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer context_extensions :manually_approves_followers, :featured, :also_known_as, :moved_to, :property_value, :identity_proof, :discoverable, :olm, :suspended, :other_setting, - :vcard + :vcard, + :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 @@ -167,6 +169,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer object.created_at.midnight.iso8601 end + def searchable_by + ActivityPub::TagManager.instance.account_searchable_by(object) + end + def bday object.birthday end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 6657f9b22..540a00bea 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true class ActivityPub::NoteSerializer < ActivityPub::Serializer - context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quote_uri, :expiry, :references, :emoji_reactions + context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quote_uri, :expiry, :references, :emoji_reactions, :searchable_by attributes :id, :type, :summary, :in_reply_to, :published, :url, :attributed_to, :to, :cc, :sensitive, :atom_uri, :in_reply_to_atom_uri, - :conversation, :context + :conversation, :context, + :searchable_by attribute :quote_uri, if: -> { object.quote? } attribute :misskey_quote, key: :_misskey_quote, if: -> { object.quote? } @@ -132,6 +133,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer ActivityPub::TagManager.instance.cc(object) end + def searchable_by + ActivityPub::TagManager.instance.searchable_by(object) + end + def sensitive object.account.sensitized? || object.sensitive end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 24ae529a9..d1adfa5c1 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class InitialStateSerializer < ActiveModel::Serializer - attributes :meta, :compose, :accounts, :lists, + attributes :meta, :compose, :search, :accounts, :lists, :media_attachments, :status_references, :settings has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer @@ -86,9 +86,10 @@ 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[:me] = object.current_account.id.to_s + store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy + store[:default_searchability] = object.current_account.searchability + store[:default_sensitive] = object.current_account.user.setting_default_sensitive end store[:text] = object.text if object.text @@ -96,6 +97,12 @@ class InitialStateSerializer < ActiveModel::Serializer store end + def search + store = {} + store[:default_searchability] = object.current_account.user.setting_default_search_searchability if object.current_account + store + end + def accounts store = {} store[object.current_account.id.to_s] = ActiveModelSerializers::SerializableResource.new(object.current_account, serializer: REST::AccountSerializer) if object.current_account diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 72ac0f508..889258cab 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -4,7 +4,7 @@ class REST::AccountSerializer < ActiveModel::Serializer include RoutingHelper attributes :id, :username, :acct, :display_name, :locked, :bot, :cat, :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, :subscribing_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 ec86c74aa..af01d738b 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -134,6 +134,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer :misskey_birthday, :misskey_location, :status_reference, + :searchability, ] capabilities << :profile_search unless Chewy.enabled? diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 08625424b..3786dd4d4 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -6,7 +6,8 @@ class REST::StatusSerializer < ActiveModel::Serializer :uri, :url, :replies_count, :reblogs_count, :favourites_count, :emoji_reactions_count, :emoji_reactions, :status_reference_ids, - :status_references_count, :status_referred_by_count + :status_references_count, :status_referred_by_count, + :searchability attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? @@ -102,6 +103,10 @@ class REST::StatusSerializer < ActiveModel::Serializer object.visibility 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/account_full_text_search_service.rb b/app/services/account_full_text_search_service.rb index bab5535a5..92855f329 100644 --- a/app/services/account_full_text_search_service.rb +++ b/app/services/account_full_text_search_service.rb @@ -18,7 +18,8 @@ class AccountFullTextSearchService < BaseService def perform_account_text_search! definition = parsed_query.apply(AccountsIndex.filter(term: { discoverable: true })) - results = definition.order(last_status_at: :desc).limit(@limit).offset(@offset).objects.compact + result_ids = definition.order(last_status_at: :desc).limit(@limit).offset(@offset).pluck(:id).compact + results = Account.where(id: result_ids) account_ids = results.map(&:id) preloaded_relations = relations_map_for_account(@account, account_ids) diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index b89c7d325..a12ead4c4 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -23,6 +23,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 create_account if @account.nil? @@ -42,6 +43,7 @@ class ActivityPub::ProcessAccountService < BaseService after_key_change! if key_changed? && !@options[:signed_with_known_key] clear_tombstones! if key_changed? after_suspension_change! if suspension_changed? + # after_searchability_change! if searchability_changed? unless @options[:only_key] || @account.suspended? check_featured_collection! if @account.featured_collection_url.present? @@ -101,6 +103,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.settings = defer_settings.merge(other_settings, birthday, address, is_cat) @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! @@ -153,6 +156,10 @@ class ActivityPub::ProcessAccountService < BaseService end end + def after_searchability_change! + SearchabilityUpdateWorker.perform_async(@account.id) if @account.statuses.unset_searchability.exists? + end + def check_featured_collection! ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id) end @@ -207,6 +214,24 @@ class ActivityPub::ProcessAccountService < BaseService end end + def audience_searchable_by + return nil if @json['searchableBy'].nil? + + as_array(@json['searchableBy']).map { |x| value_or_id(x) } + end + + def searchability_from_audience + if audience_searchable_by.nil? + :direct + elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) } + :public + elsif audience_searchable_by.include?(@account.followers_url) + :private + else + :direct + end + end + def property_values return unless @json['attachment'].is_a?(Array) as_array(@json['attachment']).select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') } @@ -322,6 +347,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 lock_options { redis: Redis.current, key: "process_account:#{@uri}", autorelease: 15.minutes.seconds } end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 467ee2d12..225fd700a 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -34,6 +34,7 @@ class FanOutOnWriteService < BaseService if !status.reblog? && (!status.reply? || status.in_reply_to_account_id == status.account_id) deliver_to_public(status) + deliver_to_index(status) if status.media_attachments.any? deliver_to_media(status) else @@ -247,6 +248,10 @@ class FanOutOnWriteService < BaseService end end + def deliver_to_index(status) + Redis.current.publish('timeline:index', @payload) if status.local? && status.public_searchability? + end + def deliver_to_media(status) Rails.logger.debug "Delivering status #{status.id} to media timeline" diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 7686cfa98..c9491fa41 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -24,6 +24,7 @@ class PostStatusService < BaseService # @option [Doorkeeper::Application] :application # @option [String] :idempotency Optional idempotency key # @option [Boolean] :with_rate_limit + # @option [String] :searchability # @return [Status] def call(account, options = {}) @account = account @@ -76,6 +77,7 @@ class PostStatusService < BaseService @visibility = :private if %i(public unlisted).include?(@visibility&.to_sym) && @account.hard_silenced? @visibility = :limited if @circle.present? @visibility = :limited if @visibility&.to_sym != :direct && @in_reply_to&.limited_visibility? + @searchability = searchability @scheduled_at = @options[:scheduled_at].is_a?(Time) ? @options[:scheduled_at] : @options[:scheduled_at]&.to_datetime&.to_time @scheduled_at = nil if scheduled_in_the_past? if @quote_id.nil? && md = @text.match(/QT:\s*\[\s*(https:\/\/.+?)\s*\]/) @@ -86,6 +88,19 @@ class PostStatusService < BaseService raise ActiveRecord::RecordInvalid end + def searchability + case @options[:searchability]&.to_sym + when :public + case @visibility&.to_sym when :public then :public when :unlisted, :private then :private else :direct end + when :unlisted, :private + case @visibility&.to_sym when :public, :unlisted, :private then :private else :direct end + when nil + @account.searchability + else + :direct + end + end + def preprocess_quote! if @quote_id.present? quote = Status.find(@quote_id) @@ -223,6 +238,7 @@ class PostStatusService < BaseService quote_id: @quote_id, expires_at: @expires_at, expires_action: @expires_action, + searchability: @searchability }.compact end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 01ac3fe15..d8c5c0081 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -2,13 +2,14 @@ class SearchService < BaseService def call(query, account, limit, options = {}) - @query = query&.strip - @account = account - @options = options - @limit = limit.to_i - @offset = options[:type].blank? ? 0 : options[:offset].to_i - @resolve = options[:resolve] || false - @profile = options[:with_profiles] || false + @query = query&.strip + @account = account + @options = options + @limit = limit.to_i + @offset = options[:type].blank? ? 0 : options[:offset].to_i + @resolve = options[:resolve] || false + @profile = options[:with_profiles] || false + @searchability = options[:searchability] || @account.user&.setting_default_search_searchability || 'private' default_results.tap do |results| next if @query.blank? || @limit.zero? @@ -69,6 +70,14 @@ class SearchService < BaseService def perform_statuses_search! definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id })) + case @searchability + when 'public' + definition = definition.or(StatusesIndex.filter(term: { searchability: 'public' })) + definition = definition.or(StatusesIndex.filter(terms: { searchability: %w(unlisted private) }).filter(terms: { account_id: following_account_ids})) unless following_account_ids.empty? + when 'unlisted', 'private' + definition = definition.or(StatusesIndex.filter(terms: { searchability: %w(public unlisted private) }).filter(terms: { account_id: following_account_ids})) unless following_account_ids.empty? + end + if @options[:account_id].present? definition = definition.filter(term: { account_id: @options[:account_id] }) end @@ -80,7 +89,8 @@ class SearchService < BaseService definition = definition.filter(range: { id: range }) end - results = definition.limit(@limit).offset(@offset).objects.compact + result_ids = definition.limit(@limit).offset(@offset).pluck(:id).compact + results = Status.where(id: result_ids) account_ids = results.map(&:account_id) account_relations = relations_map_for_account(@account, account_ids) status_relations = relations_map_for_status(@account, results) @@ -181,4 +191,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 000000000..fd5863893 --- /dev/null +++ b/app/services/searchability_update_service.rb @@ -0,0 +1,25 @@ +# 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 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) 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 70993810f..058312ee8 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -40,6 +40,11 @@ .fields-group = f.input :setting_show_application, as: :boolean, wrapper: :with_label, recommended: true + %h4= t 'preferences.searching_defaults' + + .fields-group + = f.input :setting_default_search_searchability, collection: Status.selectable_searchabilities, wrapper: :with_label, include_blank: false, label_method: lambda { |searchability| safe_join([I18n.t("search.searchabilities.#{searchability}"), I18n.t("search.searchabilities.#{searchability}_long")], ' - ') }, required: false, hint: true, fedibird_features: true + %h4= t 'preferences.fedibird_features' .fields-group diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index ca941881c..33e73f562 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -55,6 +55,10 @@ %input{ type: :text, maxlength: '999', spellcheck: 'false', readonly: 'true', value: link_to('Mastodon', ActivityPub::TagManager.instance.url_for(@account), rel: 'me').to_str } %button{ type: :button }= t('generic.copy') + .fields-group + = f.input :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, fedibird_features: true + %p.warning-hint= t('simple_form.hints.defaults.searchability') + .fields-row .fields-row__column.fields-group.fields-row__column-8 = f.input :birthday, wrapper: :with_label, input_html: { placeholder: '2016-03-16', pattern: '\d{4}-\d{1,2}-\d{1,2}' }, hint: t('simple_form.hints.defaults.birthday'), fedibird_features: true diff --git a/app/workers/searchability_update_worker.rb b/app/workers/searchability_update_worker.rb new file mode 100644 index 000000000..ce288f813 --- /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 bf2375fd5..fea44747c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1249,6 +1249,7 @@ en: other: Other posting_defaults: Posting defaults public_timelines: Public timelines + searching_defaults: Searching default reactions: errors: limit_reached: Limit of different reactions reached @@ -1293,6 +1294,14 @@ en: over_daily_limit: You have exceeded the limit of %{limit} scheduled posts for today over_total_limit: You have exceeded the limit of %{limit} scheduled posts too_soon: The scheduled date must be in the future + search: + searchabilities: + direct: Reacted-users-only + direct_long: Only search to own or reacted posts + private: Followers-only + private_long: Only search to own or followers or reacted posts + public: Public + public_long: Everyone can search sessions: activity: Last activity browser: Browser @@ -1412,6 +1421,13 @@ en: one: '%{count} other / ' other: '%{count} others / ' references_private: (Private reference) + searchabilities: + direct: Reacted-users-only + direct_long: Only users who mention or react to your post can search your post + private: Followers-only + private_long: Only users who your followers, or mention or react to your post can search your post + public: Public + public_long: Everyone can search your post (also published to external search services) 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 4ccc5d192..c690fa9d8 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1195,6 +1195,7 @@ ja: other: その他 posting_defaults: デフォルトの投稿設定 public_timelines: 公開タイムライン + searching_defaults: デフォルトの検索設定 reactions: errors: limit_reached: リアクションの種類が上限に達しました @@ -1239,6 +1240,14 @@ ja: over_daily_limit: その日予約できる投稿数 %{limit} を超えています over_total_limit: 予約できる投稿数 %{limit} を超えています too_soon: より先の時間を指定してください + search: + searchabilities: + direct: リアクション限定 + direct_long: 自身の投稿、リアクションした投稿を検索できます + private: フォロワー限定 + private_long: フォローしているアカウントの投稿、リアクション限定の投稿を検索できます + public: 公開 + public_long: 検索を公開に指定した投稿、フォロワー限定、リアクション限定の投稿を検索できます sessions: activity: 最後のアクティビティ browser: ブラウザ @@ -1349,6 +1358,13 @@ ja: zero: '' other: '他%{count}件 / ' references_private: (限定公開の参照) + searchabilities: + direct: リアクション限定 + direct_long: あなたの投稿にメンションやリアクションした相手だけがあなたの投稿を検索できます + private: フォロワー限定 + private_long: あなたのフォロワーと、あなたの投稿にメンション・リアクションした相手だけがあなたの投稿を検索できます + public: 公開 + public_long: 誰でもあなたの投稿を検索できます(外部検索サービスにも公開されます) show_more: もっと見る show_newer: 新しいものから表示 show_older: 古いものから表示 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index b77791d9d..fd0dc05d7 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -53,9 +53,11 @@ en: password: Use at least 8 characters phrase: Will be matched regardless of casing in text or content warning of a post scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones. + searchability: This specification currently works as expected only in Fedibird. Regardless of what you specify, Mastodon uses "Reacted-users-only" behavior, external search sites often search only collected public posts, and Misskey searches all posts. setting_aggregate_reblogs: Do not show new boosts for posts that have been recently boosted (only affects newly-received boosts) setting_compact_reaction: Emoji reaction display to be only the number of cases, except for the detail display setting_confirm_follow_from_bot: Manually approve followers from bot accounts + setting_default_search_searchability: For clients that do not support advanced range settings, switch the settings here. Mastodon's standard behavior is "Reacted-users-only". Targeting "Public" makes it easier to discover unknown information, but if the results are noisy, narrowing the search range is effective. setting_default_sensitive: Sensitive media is hidden by default and can be revealed with a click setting_disable_joke_appearance: Disable April Fools' Day and other joke functions setting_display_media_default: Hide media marked as sensitive @@ -205,6 +207,7 @@ en: otp_attempt: Two-factor code password: Password phrase: Keyword or phrase + searchability: Search scope for your posts setting_add_reference_modal: Show confirmation dialog before adding a reference to a private post setting_advanced_layout: Enable advanced web interface setting_aggregate_reblogs: Group boosts in timelines @@ -217,6 +220,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_search_searchability: Search range setting_default_sensitive: Always mark media as sensitive setting_delete_modal: Show confirmation dialog before deleting a post setting_disable_joke_appearance: Disable joke feature to change appearance diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index ce6d457fc..fc3bb1f8e 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -53,9 +53,11 @@ ja: password: 少なくとも8文字は入力してください phrase: 投稿内容の大文字小文字や閲覧注意に関係なく一致 scopes: アプリの API に許可するアクセス権を選択してください。最上位のスコープを選択する場合、個々のスコープを選択する必要はありません。 + searchability: この指定は、現時点ではFedibirdでのみ期待通り動作します。指定にかかわらず、Mastodonは『リアクション限定』の動作を、外部検索サイトは収集した公開投稿全てを検索対象にすることが多く、Misskeyは全ての投稿を検索対象にします。 setting_aggregate_reblogs: 最近ブーストされた投稿が新たにブーストされても表示しません (設定後受信したものにのみ影響) setting_compact_reaction: 詳細表示以外の絵文字リアクション表示を件数のみにする setting_confirm_follow_from_bot: Botアカウントからのフォローを手動で承認する + setting_default_search_searchability: 範囲の詳細設定に対応していないクライアントでは、ここで設定を切り替えてください。Mastodonの標準動作は『リアクション限定』です。『公開』を対象にすると未知の情報を発見しやすくなりますが、結果にノイズが多い場合は検索範囲を狭めると効果的です。 setting_default_sensitive: 閲覧注意状態のメディアはデフォルトでは内容が伏せられ、クリックして初めて閲覧できるようになります setting_disable_joke_appearance: エイプリルフール等のジョーク機能を無効にします setting_display_media_default: 閲覧注意としてマークされたメディアは隠す @@ -205,6 +207,7 @@ ja: otp_attempt: 二段階認証コード password: パスワード phrase: キーワードまたはフレーズ + searchability: あなたの投稿の検索範囲 setting_add_reference_modal: フォロワー限定投稿への参照を追加する前に確認ダイアログを表示する setting_advanced_layout: 上級者向け UI を有効にする setting_aggregate_reblogs: ブーストをまとめる @@ -217,6 +220,7 @@ ja: setting_crop_images: 投稿の詳細以外では画像を16:9に切り抜く setting_default_language: 投稿する言語 setting_default_privacy: 投稿の公開範囲 + setting_default_search_searchability: 検索の対象とする範囲 setting_default_sensitive: メディアを常に閲覧注意としてマークする setting_delete_modal: 投稿を削除する前に確認ダイアログを表示する setting_disable_joke_appearance: ジョーク機能による見た目の変更を無効にする diff --git a/config/settings.yml b/config/settings.yml index 3899006e4..ecc2d7f96 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -106,6 +106,7 @@ defaults: &defaults enable_empty_column: false hide_bot_on_public_timeline: false confirm_follow_from_bot: true + default_search_searchability: 'private' development: <<: *defaults diff --git a/db/migrate/20220817075513_add_searchability_to_status.rb b/db/migrate/20220817075513_add_searchability_to_status.rb new file mode 100644 index 000000000..8c3bdf680 --- /dev/null +++ b/db/migrate/20220817075513_add_searchability_to_status.rb @@ -0,0 +1,13 @@ +class AddSearchabilityToStatus < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def up + add_column :statuses, :searchability, :integer + add_index :statuses, [:account_id, :id], where: 'deleted_at IS NULL AND expired_at IS NULL AND reblog_of_id IS NULL AND searchability IN (0, 1, 2)', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_private_searchable + end + + def down + remove_index :statuses, name: :index_statuses_private_searchable + remove_column :statuses, :searchability + end +end diff --git a/db/migrate/20220817075643_add_searchability_to_account.rb b/db/migrate/20220817075643_add_searchability_to_account.rb new file mode 100644 index 000000000..517e1d9f4 --- /dev/null +++ b/db/migrate/20220817075643_add_searchability_to_account.rb @@ -0,0 +1,17 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddSearchabilityToAccount < ActiveRecord::Migration[6.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured { add_column_with_default :accounts, :searchability, :integer, default: 3, allow_null: false } + safety_assured { add_index :accounts, :searchability, algorithm: :concurrently } + end + + def down + remove_index :accounts, :searchability + remove_column :accounts, :searchability + end +end diff --git a/db/post_migrate/20220823185527_conservative_setting_to_default_search_searchability.rb b/db/post_migrate/20220823185527_conservative_setting_to_default_search_searchability.rb new file mode 100644 index 000000000..d088e24d6 --- /dev/null +++ b/db/post_migrate/20220823185527_conservative_setting_to_default_search_searchability.rb @@ -0,0 +1,11 @@ +class ConservativeSettingToDefaultSearchSearchability < ActiveRecord::Migration[6.1] + def up + User.joins('join settings on users.id = settings.thing_id').where(settings: {thing_type: :User, var: :new_features_policy, value: "--- conservative\n"}).find_each do |user| + user.settings['default_search_searchability'] = 'direct' + end + end + + def down + # nothing to do + end +end diff --git a/db/schema.rb b/db/schema.rb index 74007918d..bc8ea3057 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -209,9 +209,11 @@ ActiveRecord::Schema.define(version: 2023_01_29_193248) do t.datetime "sensitized_at" t.jsonb "settings", default: "{}", null: false t.integer "silence_mode", default: 0, null: false + t.integer "searchability", default: 3, 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" + t.index ["searchability"], name: "index_accounts_on_searchability" t.index ["settings"], name: "index_accounts_on_settings", using: :gin t.index ["uri"], name: "index_accounts_on_uri" t.index ["url"], name: "index_accounts_on_url" @@ -991,7 +993,9 @@ ActiveRecord::Schema.define(version: 2023_01_29_193248) do t.datetime "deleted_at" t.bigint "quote_id" t.datetime "expired_at" + t.integer "searchability" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20210710", order: { id: :desc }, where: "((deleted_at IS NULL) AND (expired_at IS NULL))" + t.index ["account_id", "id"], name: "index_statuses_private_searchable", order: { id: :desc }, where: "((deleted_at IS NULL) AND (expired_at IS NULL) AND (reblog_of_id IS NULL) AND (searchability = ANY (ARRAY[0, 1, 2])))" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" diff --git a/streaming/index.js b/streaming/index.js index 3b7e4b833..a4cbfb38f 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -840,26 +840,36 @@ const startWorker = (workerId) => { break; case 'public:local': - if (!isImast(req) && !isMastodonForiOS(req) && !isMastodonForAndroid(req)) { + if (!req.accountId) { + resolve({ + channelIds: ['timeline:index'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else if (isImast(req) || isMastodonForiOS(req) || isMastodonForAndroid(req)) { + resolve({ + channelIds: ['timeline:public'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else { reject('No local stream provided'); } - resolve({ - channelIds: ['timeline:public'], - options: { needsFiltering: true, notificationOnly: false }, - }); - break; case 'public:local:nobot': - if (!isImast(req) && !isMastodonForiOS(req) && !isMastodonForAndroid(req)) { + if (!req.accountId) { + resolve({ + channelIds: ['timeline:index'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else if (isImast(req) || isMastodonForiOS(req) || isMastodonForAndroid(req)) { + resolve({ + channelIds: ['timeline:public:nobot'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else { reject('No local stream provided'); } - resolve({ - channelIds: ['timeline:public:nobot'], - options: { needsFiltering: true, notificationOnly: false }, - }); - break; case 'public:remote': resolve({ @@ -923,26 +933,36 @@ const startWorker = (workerId) => { break; case 'public:local:media': - if (!isImast(req) && !isMastodonForiOS(req) && !isMastodonForAndroid(req)) { - reject('No local media stream provided'); + if (!req.accountId) { + resolve({ + channelIds: ['timeline:index'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else if (isImast(req) || isMastodonForiOS(req) || isMastodonForAndroid(req)) { + resolve({ + channelIds: ['timeline:public:media'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else { + reject('No local stream provided'); } - resolve({ - channelIds: ['timeline:public:media'], - options: { needsFiltering: true, notificationOnly: false }, - }); - break; case 'public:local:nobot:media': - if (!isImast(req) && !isMastodonForiOS(req) && !isMastodonForAndroid(req)) { - reject('No local media stream provided'); + if (!req.accountId) { + resolve({ + channelIds: ['timeline:index'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else if (isImast(req) || isMastodonForiOS(req) || isMastodonForAndroid(req)) { + resolve({ + channelIds: ['timeline:public:nobot:media'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else { + reject('No local stream provided'); } - resolve({ - channelIds: ['timeline:public:nobot:media'], - options: { needsFiltering: true, notificationOnly: false }, - }); - break; case 'public:remote:media': resolve({ @@ -995,26 +1015,36 @@ const startWorker = (workerId) => { break; case 'public:local:nomedia': - if (!isImast(req) && !isMastodonForiOS(req) && !isMastodonForAndroid(req)) { - reject('No local nomedia stream provided'); + if (!req.accountId) { + resolve({ + channelIds: ['timeline:index'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else if (isImast(req) || isMastodonForiOS(req) || isMastodonForAndroid(req)) { + resolve({ + channelIds: ['timeline:public:nomedia'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else { + reject('No local stream provided'); } - resolve({ - channelIds: ['timeline:public:nomedia'], - options: { needsFiltering: true, notificationOnly: false }, - }); - break; case 'public:local:nobot:nomedia': - if (!isImast(req) && !isMastodonForiOS(req) && !isMastodonForAndroid(req)) { - reject('No local nomedia stream provided'); + if (!req.accountId) { + resolve({ + channelIds: ['timeline:index'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else if (isImast(req) || isMastodonForiOS(req) || isMastodonForAndroid(req)) { + resolve({ + channelIds: ['timeline:public:nobot:nomedia'], + options: { needsFiltering: true, notificationOnly: false }, + }); + } else { + reject('No local stream provided'); } - resolve({ - channelIds: ['timeline:public:nobot:nomedia'], - options: { needsFiltering: true, notificationOnly: false }, - }); - break; case 'public:remote:nomedia': resolve({