Add searchability features
This commit is contained in:
parent
cf18642b99
commit
91d6b018df
49 changed files with 857 additions and 127 deletions
34
Gemfile.lock
34
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
|
@ -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",
|
||||
|
|
|
@ -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": "ハッシュタグ",
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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? }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -134,6 +134,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
:misskey_birthday,
|
||||
:misskey_location,
|
||||
:status_reference,
|
||||
:searchability,
|
||||
]
|
||||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
25
app/services/searchability_update_service.rb
Normal file
25
app/services/searchability_update_service.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
13
app/workers/searchability_update_worker.rb
Normal file
13
app/workers/searchability_update_worker.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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: 古いものから表示
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: ジョーク機能による見た目の変更を無効にする
|
||||
|
|
|
@ -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
|
||||
|
|
13
db/migrate/20220817075513_add_searchability_to_status.rb
Normal file
13
db/migrate/20220817075513_add_searchability_to_status.rb
Normal 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
|
17
db/migrate/20220817075643_add_searchability_to_account.rb
Normal file
17
db/migrate/20220817075643_add_searchability_to_account.rb
Normal 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
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Reference in a new issue