Add searchability features

This commit is contained in:
noellabo 2022-08-23 14:14:23 +09:00
parent cf18642b99
commit 91d6b018df
49 changed files with 857 additions and 127 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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)
)

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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 {
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<DateTimeButtonContainer />
<SearchabilityDropdownContainer />
</div>
<div className='character-counter__wrapper'><CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} /></div>
</div>

View file

@ -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 (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ 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
<div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<Icon id='search' fixedWidth />
<Icon id={item.icon} fixedWidth />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div>
)}
</Motion>
);
}
}
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 (
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })}>
<IconButton
className='privacy-dropdown__value-icon'
icon='search'
// icon={valueOption.icon}
title={intl.formatMessage(messages.change_searchability)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
/>
</div>
<Overlay show={open} placement={placement} target={this} container={container}>
<SearchabilityDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
placement={placement}
/>
</Overlay>
</div>
);
}
}

View file

@ -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);

View file

@ -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",

View file

@ -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": "ハッシュタグ",

View file

@ -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'));

View file

@ -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);

View file

@ -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

View file

@ -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),

View file

@ -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)

View file

@ -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

View file

@ -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? }

View file

@ -21,12 +21,14 @@ class PublicFeed
# @param [Integer] min_id
# @return [Array<Status>]
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

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -134,6 +134,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:misskey_birthday,
:misskey_location,
:status_reference,
:searchability,
]
capabilities << :profile_search unless Chewy.enabled?

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: 古いものから表示

View file

@ -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

View file

@ -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: ジョーク機能による見た目の変更を無効にする

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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({