Add searchability support
This commit is contained in:
parent
a2e674af51
commit
af20b1d2aa
43 changed files with 716 additions and 65 deletions
|
@ -3,7 +3,37 @@
|
|||
class StatusesIndex < Chewy::Index
|
||||
include FormattingHelper
|
||||
|
||||
settings index: { refresh_interval: '30s' }, analysis: {
|
||||
DEVELOPMENT_SETTINGS = {
|
||||
filter: {
|
||||
english_stop: {
|
||||
type: 'stop',
|
||||
stopwords: '_english_',
|
||||
},
|
||||
english_stemmer: {
|
||||
type: 'stemmer',
|
||||
language: 'english',
|
||||
},
|
||||
english_possessive_stemmer: {
|
||||
type: 'stemmer',
|
||||
language: 'possessive_english',
|
||||
},
|
||||
},
|
||||
analyzer: {
|
||||
content: {
|
||||
tokenizer: 'uax_url_email',
|
||||
filter: %w(
|
||||
english_possessive_stemmer
|
||||
lowercase
|
||||
asciifolding
|
||||
cjk_width
|
||||
english_stop
|
||||
english_stemmer
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
PRODUCTION_SETTINGS = {
|
||||
filter: {
|
||||
english_stop: {
|
||||
type: 'stop',
|
||||
|
@ -46,6 +76,8 @@ class StatusesIndex < Chewy::Index
|
|||
}
|
||||
}
|
||||
|
||||
settings index: { refresh_interval: '30s' }, analysis: Rails.env.development? ? DEVELOPMENT_SETTINGS : PRODUCTION_SETTINGS
|
||||
|
||||
# We do not use delete_if option here because it would call a method that we
|
||||
# expect to be called with crutches without crutches, causing n+1 queries
|
||||
index_scope ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll)
|
||||
|
@ -89,5 +121,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
|
||||
|
|
|
@ -29,6 +29,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
|||
:locked,
|
||||
:bot,
|
||||
:discoverable,
|
||||
:searchability,
|
||||
:hide_collections,
|
||||
fields_attributes: [:name, :value]
|
||||
)
|
||||
|
|
|
@ -59,6 +59,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
sensitive: status_params[:sensitive],
|
||||
spoiler_text: status_params[:spoiler_text],
|
||||
visibility: status_params[:visibility],
|
||||
searchability: status_params[:searchability],
|
||||
language: status_params[:language],
|
||||
scheduled_at: status_params[:scheduled_at],
|
||||
application: doorkeeper_token.application,
|
||||
|
@ -133,6 +134,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
:sensitive,
|
||||
:spoiler_text,
|
||||
:visibility,
|
||||
:searchability,
|
||||
:language,
|
||||
:scheduled_at,
|
||||
allowed_mentions: [],
|
||||
|
|
|
@ -34,6 +34,7 @@ class Settings::PreferencesController < Settings::BaseController
|
|||
def user_settings_params
|
||||
params.require(:user).permit(
|
||||
:setting_default_privacy,
|
||||
:setting_default_searchability,
|
||||
:setting_default_sensitive,
|
||||
:setting_default_language,
|
||||
:setting_unfollow_modal,
|
||||
|
|
|
@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController
|
|||
private
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :my_actor_type, :group_allow_private_message, :discoverable, :hide_collections, fields_attributes: [:name, :value])
|
||||
params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :my_actor_type, :searchability, :group_allow_private_message, :discoverable, :hide_collections, fields_attributes: [:name, :value])
|
||||
end
|
||||
|
||||
def set_account
|
||||
|
|
|
@ -202,9 +202,13 @@ module ApplicationHelper
|
|||
}
|
||||
|
||||
permit_visibilities = %w(public unlisted public_unlisted private direct)
|
||||
permit_searchabilities = %w(public unlisted public_unlisted private direct)
|
||||
default_privacy = current_account&.user&.setting_default_privacy
|
||||
permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present?
|
||||
state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility]
|
||||
default_searchability = current_account&.user&.setting_default_searchability
|
||||
permit_searchabilities.shift(permit_searchabilities.index(default_privacy) + 1) if default_searchability.present?
|
||||
state_params[:searchability] = params[:searchability] if permit_searchabilities.include? params[:searchability]
|
||||
|
||||
if user_signed_in? && current_user.functional?
|
||||
state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
|
||||
|
|
|
@ -21,6 +21,8 @@ module ContextHelper
|
|||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
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' } },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
}.freeze
|
||||
|
|
|
@ -53,6 +53,7 @@ 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_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
|
||||
|
||||
|
@ -192,6 +193,7 @@ export function submitCompose(routerHistory) {
|
|||
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
searchability: getState().getIn(['compose', 'searchability']),
|
||||
poll: getState().getIn(['compose', 'poll'], null),
|
||||
language: getState().getIn(['compose', 'language']),
|
||||
},
|
||||
|
@ -734,6 +736,13 @@ export function changeComposeVisibility(value) {
|
|||
};
|
||||
}
|
||||
|
||||
export function changeComposeSearchability(value) {
|
||||
return {
|
||||
type: COMPOSE_SEARCHABILITY_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertEmojiCompose(position, emoji, needsSpace) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
|
|
|
@ -11,6 +11,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 ExpirationDropdownContainer from '../containers/expiration_dropdown_container';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import PollFormContainer from '../containers/poll_form_container';
|
||||
|
@ -45,6 +46,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
suggestions: ImmutablePropTypes.list,
|
||||
spoiler: PropTypes.bool,
|
||||
privacy: PropTypes.string,
|
||||
searchability: PropTypes.string,
|
||||
spoilerText: PropTypes.string,
|
||||
focusDate: PropTypes.instanceOf(Date),
|
||||
caretPosition: PropTypes.number,
|
||||
|
@ -272,6 +274,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
lang={this.props.lang}
|
||||
>
|
||||
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
||||
<ExpirationDropdownContainer onPickExpiration={this.handleExpirationPick} />
|
||||
|
||||
<div className='compose-form__modifiers'>
|
||||
<UploadFormContainer />
|
||||
|
@ -284,9 +287,9 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<UploadButtonContainer />
|
||||
<PollButtonContainer />
|
||||
<PrivacyDropdownContainer disabled={this.props.isEditing} />
|
||||
<SearchabilityDropdownContainer disabled={this.props.isEditing} />
|
||||
<SpoilerButtonContainer />
|
||||
<LanguageDropdown />
|
||||
<ExpirationDropdownContainer onPickExpiration={this.handleExpirationPick} />
|
||||
</div>
|
||||
|
||||
<div className='character-counter__wrapper'>
|
||||
|
|
|
@ -5,7 +5,6 @@ import IconButton from '../../../components/icon_button';
|
|||
import Overlay from 'react-overlays/Overlay';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
add_expiration: { id: 'status.expiration.add', defaultMessage: 'Set status expiration' },
|
||||
|
@ -108,7 +107,7 @@ class ExpirationDropdownMenu extends React.PureComponent {
|
|||
<div style={{ ...style }} 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__content'>
|
||||
<div className='expiration-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -229,10 +228,10 @@ class ExpirationDropdown extends React.PureComponent {
|
|||
const { open, placement } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
|
||||
<div className={classNames('privacy-dropdown__value')} ref={this.setTargetRef}>
|
||||
<div className={classNames('expiration-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
|
||||
<div className={classNames('expiration-dropdown__value')} ref={this.setTargetRef}>
|
||||
<IconButton
|
||||
className='privacy-dropdown__value-icon'
|
||||
className='expiration-dropdown__value-icon'
|
||||
icon='clock-o'
|
||||
title={intl.formatMessage(messages.add_expiration)}
|
||||
size={18}
|
||||
|
@ -250,7 +249,7 @@ class ExpirationDropdown extends React.PureComponent {
|
|||
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
|
||||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||
<div className={`dropdown-animation expiration-dropdown__dropdown ${placement}`}>
|
||||
<ExpirationDropdownMenu
|
||||
items={this.options}
|
||||
value={value}
|
||||
|
|
|
@ -0,0 +1,286 @@
|
|||
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/Overlay';
|
||||
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: 'Anyone can find' },
|
||||
unlisted_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
|
||||
unlisted_long: { id: 'searchability.unlisted.long', defaultMessage: 'Your followers can find' },
|
||||
private_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' },
|
||||
private_long: { id: 'searchability.private.long', defaultMessage: 'Reacter of this post can find' },
|
||||
direct_short: { id: 'searchability.direct.short', defaultMessage: 'Self only' },
|
||||
direct_long: { id: 'searchability.direct.long', defaultMessage: 'Nobody can find, but you can' },
|
||||
change_searchability: { id: 'searchability.change', defaultMessage: 'Set 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,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
setFocusRef = c => {
|
||||
this.focusedItem = c;
|
||||
};
|
||||
|
||||
render () {
|
||||
const { style, items, value } = this.props;
|
||||
|
||||
return (
|
||||
<div style={{ ...style }} 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={item.icon} fixedWidth />
|
||||
</div>
|
||||
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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,
|
||||
disabled: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
open: false,
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
handleToggle = () => {
|
||||
if (this.props.isUserTouching && this.props.isUserTouching()) {
|
||||
if (this.state.open) {
|
||||
this.props.onModalClose();
|
||||
} else {
|
||||
this.props.onModalOpen({
|
||||
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
|
||||
onClick: this.handleModalActionClick,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (this.state.open && this.activeElement) {
|
||||
this.activeElement.focus({ preventScroll: true });
|
||||
}
|
||||
this.setState({ open: !this.state.open });
|
||||
}
|
||||
};
|
||||
|
||||
handleModalActionClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
|
||||
|
||||
this.props.onModalClose();
|
||||
this.props.onChange(value);
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
this.handleClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseDown = () => {
|
||||
if (!this.state.open) {
|
||||
this.activeElement = document.activeElement;
|
||||
}
|
||||
};
|
||||
|
||||
handleButtonKeyDown = (e) => {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
this.handleMouseDown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
if (this.state.open && this.activeElement) {
|
||||
this.activeElement.focus({ preventScroll: true });
|
||||
}
|
||||
this.setState({ open: false });
|
||||
};
|
||||
|
||||
handleChange = value => {
|
||||
this.props.onChange(value);
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
const { intl: { formatMessage } } = this.props;
|
||||
|
||||
this.options = [
|
||||
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
|
||||
{ icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
|
||||
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
|
||||
{ icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
|
||||
];
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
};
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
};
|
||||
|
||||
handleOverlayEnter = (state) => {
|
||||
this.setState({ placement: state.placement });
|
||||
};
|
||||
|
||||
render () {
|
||||
const { value, container, disabled, intl } = this.props;
|
||||
const { open, placement } = this.state;
|
||||
|
||||
const valueOption = this.options.find(item => item.value === value);
|
||||
|
||||
return (
|
||||
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
|
||||
<div className={classNames('privacy-dropdown__value', 'searchability', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })} ref={this.setTargetRef}>
|
||||
<IconButton
|
||||
className='privacy-dropdown__value-icon'
|
||||
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' }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Icon
|
||||
className='searchability-dropdown__value-overlay'
|
||||
id='search'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
|
||||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||
<SearchabilityDropdownMenu
|
||||
items={this.options}
|
||||
value={value}
|
||||
onClose={this.handleClose}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
import ExpirationDropdown from '../components/expiration_dropdown';
|
||||
import { changeComposeVisibility } from '../../../actions/compose';
|
||||
import { openModal, closeModal } from '../../../actions/modal';
|
||||
import { isUserTouching } from '../../../is_mobile';
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -25,6 +25,10 @@ const messages = defineMessages({
|
|||
public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
|
||||
searchability_unlisted_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
|
||||
searchability_private_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' },
|
||||
searchability_direct_short: { id: 'searchability.direct.short', defaultMessage: 'Self only' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
|
@ -218,6 +222,16 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||
const visibilityLink = <React.Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></React.Fragment>;
|
||||
|
||||
const searchabilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.searchability_public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.searchability_unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.searchability_private_short) },
|
||||
'direct': { icon: 'at', text: intl.formatMessage(messages.searchability_direct_short) },
|
||||
};
|
||||
|
||||
const searchabilityIcon = searchabilityIconInfo[status.get('searchability')];
|
||||
const searchabilityLink = <React.Fragment> · <Icon id={searchabilityIcon.icon} title={searchabilityIcon.text} /></React.Fragment>;
|
||||
|
||||
if (['private', 'direct'].includes(status.get('visibility'))) {
|
||||
reblogLink = '';
|
||||
} else if (this.context.router) {
|
||||
|
@ -313,7 +327,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
<div className='detailed-status__meta'>
|
||||
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
|
||||
</a>{edited}{visibilityLink}{searchabilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -47,6 +47,7 @@ import {
|
|||
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
|
||||
COMPOSE_CHANGE_MEDIA_FOCUS,
|
||||
COMPOSE_SET_STATUS,
|
||||
COMPOSE_SEARCHABILITY_CHANGE,
|
||||
} from '../actions/compose';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
import { STORE_HYDRATE } from '../actions/store';
|
||||
|
@ -62,6 +63,7 @@ const initialState = ImmutableMap({
|
|||
spoiler: false,
|
||||
spoiler_text: '',
|
||||
privacy: null,
|
||||
searchability: null,
|
||||
id: null,
|
||||
text: '',
|
||||
focusDate: null,
|
||||
|
@ -81,6 +83,7 @@ const initialState = ImmutableMap({
|
|||
suggestion_token: null,
|
||||
suggestions: ImmutableList(),
|
||||
default_privacy: 'public',
|
||||
default_searchability: 'public',
|
||||
default_sensitive: false,
|
||||
default_language: 'en',
|
||||
resetFileKey: Math.floor((Math.random() * 0x10000)),
|
||||
|
@ -121,6 +124,7 @@ function clearAll(state) {
|
|||
map.set('is_changing_upload', false);
|
||||
map.set('in_reply_to', null);
|
||||
map.set('privacy', state.get('default_privacy'));
|
||||
map.set('searchability', state.get('default_searchability'));
|
||||
map.set('sensitive', state.get('default_sensitive'));
|
||||
map.set('language', state.get('default_language'));
|
||||
map.update('media_attachments', list => list.clear());
|
||||
|
@ -328,6 +332,10 @@ export default function compose(state = initialState, action) {
|
|||
return state
|
||||
.set('privacy', action.value)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_SEARCHABILITY_CHANGE:
|
||||
return state
|
||||
.set('searchability', action.value)
|
||||
.set('idempotencyKey', uuid());
|
||||
case COMPOSE_CHANGE:
|
||||
return state
|
||||
.set('text', action.text)
|
||||
|
@ -340,6 +348,7 @@ export default function compose(state = initialState, action) {
|
|||
map.set('in_reply_to', action.status.get('id'));
|
||||
map.set('text', statusToTextMentions(state, action.status));
|
||||
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
|
||||
map.set('searchability', privacyPreference(action.status.get('searchability'), state.get('default_searchability')))
|
||||
map.set('focusDate', new Date());
|
||||
map.set('caretPosition', null);
|
||||
map.set('preselectDate', new Date());
|
||||
|
|
|
@ -289,7 +289,8 @@ html {
|
|||
background: $ui-base-color;
|
||||
}
|
||||
|
||||
.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {
|
||||
.privacy-dropdown.active .privacy-dropdown__value.active .icon-button,
|
||||
.expiration-dropdown.active .expiration-dropdown__value.active .icon-button {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
|
@ -321,6 +322,12 @@ html {
|
|||
.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
|
||||
.privacy-dropdown__option:hover .privacy-dropdown__option__content,
|
||||
.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
|
||||
.expiration-dropdown__option.active,
|
||||
.expiration-dropdown__option:hover,
|
||||
.expiration-dropdown__option.active .expiration-dropdown__option__content,
|
||||
.expiration-dropdown__option.active .expiration-dropdown__option__content strong,
|
||||
.expiration-dropdown__option:hover .expiration-dropdown__option__content,
|
||||
.expiration-dropdown__option:hover .expiration-dropdown__option__content strong,
|
||||
.dropdown-menu__item a:active,
|
||||
.dropdown-menu__item a:focus,
|
||||
.dropdown-menu__item a:hover,
|
||||
|
|
|
@ -468,6 +468,13 @@ body > [data-popper-placement] {
|
|||
right: 0;
|
||||
}
|
||||
|
||||
.expiration-dropdown {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
right: 4px;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.compose-form__autosuggest-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
@ -4642,7 +4649,8 @@ a.status-card.compact:hover {
|
|||
filter: none;
|
||||
}
|
||||
|
||||
.privacy-dropdown__dropdown {
|
||||
.privacy-dropdown__dropdown,
|
||||
.expiration-dropdown__dropdown {
|
||||
background: $simple-background-color;
|
||||
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
|
||||
border-radius: 4px;
|
||||
|
@ -4658,16 +4666,19 @@ a.status-card.compact:hover {
|
|||
}
|
||||
}
|
||||
|
||||
.modal-root__container .privacy-dropdown {
|
||||
.modal-root__container .privacy-dropdown,
|
||||
.modal-root__container .expiration-dropdown {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.modal-root__container .privacy-dropdown__dropdown {
|
||||
.modal-root__container .privacy-dropdown__dropdown,
|
||||
.modal-root__container .ezpiration-dropdown__dropdown {
|
||||
pointer-events: auto;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.privacy-dropdown__option {
|
||||
.privacy-dropdown__option,
|
||||
.expiration-dropdown__option {
|
||||
color: $inverted-text-color;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
|
@ -4717,8 +4728,8 @@ a.status-card.compact:hover {
|
|||
}
|
||||
}
|
||||
|
||||
.privacy-dropdown.active {
|
||||
.privacy-dropdown__value {
|
||||
.privacy-dropdown.active, .expiration-dropdown.active {
|
||||
.privacy-dropdown__value, .expiration-dropdown__value {
|
||||
background: $simple-background-color;
|
||||
border-radius: 4px 4px 0 0;
|
||||
box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
|
||||
|
@ -4736,16 +4747,26 @@ a.status-card.compact:hover {
|
|||
}
|
||||
}
|
||||
|
||||
&.top .privacy-dropdown__value {
|
||||
&.top .privacy-dropdown__value, &.top .expiration-dropdown__value {
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.privacy-dropdown__dropdown {
|
||||
.privacy-dropdown__dropdown, .expiration-dropdown__dropdown {
|
||||
display: block;
|
||||
box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-dropdown .privacy-dropdown__value.searchability {
|
||||
position: relative;
|
||||
|
||||
.searchability-dropdown__value-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
&__dropdown {
|
||||
background: $simple-background-color;
|
||||
|
|
|
@ -70,6 +70,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
def audience_searchable_by
|
||||
return nil if @object['searchableBy'].nil?
|
||||
|
||||
@audience_searchable_by = as_array(@object['searchableBy']).map { |x| value_or_id(x) }
|
||||
end
|
||||
|
||||
def process_status
|
||||
@tags = []
|
||||
@mentions = []
|
||||
|
@ -122,6 +128,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
reply: @status_parser.reply,
|
||||
sensitive: @account.sensitized? || @status_parser.sensitive || false,
|
||||
visibility: @status_parser.visibility,
|
||||
searchability: searchability,
|
||||
thread: replied_to_status,
|
||||
conversation: conversation_from_uri(@object['conversation']),
|
||||
media_attachment_ids: process_attachments.take(4).map(&:id),
|
||||
|
@ -429,4 +436,32 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
def join_group!
|
||||
GroupReblogService.new.call(@status)
|
||||
end
|
||||
|
||||
def searchability_from_audience
|
||||
if audience_searchable_by.nil?
|
||||
nil
|
||||
elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) }
|
||||
:public
|
||||
elsif audience_searchable_by.include?(@account.followers_url)
|
||||
: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
|
||||
|
|
|
@ -185,4 +185,31 @@ class ActivityPub::TagManager
|
|||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
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
|
||||
[]
|
||||
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
|
||||
end
|
||||
|
|
|
@ -3,10 +3,11 @@
|
|||
class StatusFilter
|
||||
attr_reader :status, :account
|
||||
|
||||
def initialize(status, account, preloaded_relations = {})
|
||||
def initialize(status, account, preloaded_relations = {}, preloaded_status_relations = {})
|
||||
@status = status
|
||||
@account = account
|
||||
@preloaded_relations = preloaded_relations
|
||||
@preloaded_status_relations = preloaded_status_relations
|
||||
end
|
||||
|
||||
def filtered?
|
||||
|
@ -15,12 +16,6 @@ class StatusFilter
|
|||
blocked_by_policy? || (account_present? && filtered_status?) || silenced_account?
|
||||
end
|
||||
|
||||
def search_filtered?
|
||||
return false if !account.nil? && account.id == status.account_id
|
||||
|
||||
blocked_by_policy_search? || (account_present? && filtered_status?) || silenced_account?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_present?
|
||||
|
@ -59,15 +54,7 @@ class StatusFilter
|
|||
!policy_allows_show?
|
||||
end
|
||||
|
||||
def blocked_by_policy_search?
|
||||
!policy_allows_search?
|
||||
end
|
||||
|
||||
def policy_allows_show?
|
||||
StatusPolicy.new(account, status, @preloaded_relations).show?
|
||||
end
|
||||
|
||||
def policy_allows_search?
|
||||
StatusPolicy.new(account, status, @preloaded_relations).search?
|
||||
StatusPolicy.new(account, status, @preloaded_relations, @preloaded_status_relations).show?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,6 +18,7 @@ class UserSettingsDecorator
|
|||
user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails')
|
||||
user.settings['interactions'] = merged_interactions if change?('interactions')
|
||||
user.settings['default_privacy'] = default_privacy_preference if change?('setting_default_privacy')
|
||||
user.settings['default_searchability']=default_searchability_preference if change?('setting_default_searchability')
|
||||
user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive')
|
||||
user.settings['default_language'] = default_language_preference if change?('setting_default_language')
|
||||
user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal')
|
||||
|
@ -53,6 +54,10 @@ class UserSettingsDecorator
|
|||
settings['setting_default_privacy']
|
||||
end
|
||||
|
||||
def default_searchability_preference
|
||||
settings['setting_default_searchability']
|
||||
end
|
||||
|
||||
def default_sensitive_preference
|
||||
boolean_cast_setting 'setting_default_sensitive'
|
||||
end
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
# reviewed_at :datetime
|
||||
# requested_review_at :datetime
|
||||
# group_allow_private_message :boolean
|
||||
# searchability :integer default(0), not null
|
||||
#
|
||||
|
||||
class Account < ApplicationRecord
|
||||
|
@ -82,6 +83,7 @@ class Account < ApplicationRecord
|
|||
|
||||
enum protocol: { ostatus: 0, activitypub: 1 }
|
||||
enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
|
||||
enum searchability: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10 }, _suffix: :searchability
|
||||
|
||||
validates :username, presence: true
|
||||
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
|
||||
|
|
|
@ -237,6 +237,10 @@ module AccountInteractions
|
|||
status.proper.favourites.where(account: self).exists?
|
||||
end
|
||||
|
||||
def emoji_reactioned?(status)
|
||||
status.proper.emoji_reactions.where(account: self).exists?
|
||||
end
|
||||
|
||||
def bookmarked?(status)
|
||||
status.proper.bookmarks.where(account: self).exists?
|
||||
end
|
||||
|
|
|
@ -29,6 +29,7 @@ class PublicFeed
|
|||
scope.merge!(account_filters_scope) if account?
|
||||
scope.merge!(media_only_scope) if media_only?
|
||||
scope.merge!(language_scope) if account&.chosen_languages.present?
|
||||
scope.merge!(public_searchable_scope) if local_only? && !account?
|
||||
|
||||
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
|
||||
end
|
||||
|
@ -97,6 +98,10 @@ class PublicFeed
|
|||
Status.where(language: account.chosen_languages)
|
||||
end
|
||||
|
||||
def public_searchable_scope
|
||||
Status.where(searchability: 'public').or(Status.where(searchability: nil).merge(Account.where(searchability: 'public')))
|
||||
end
|
||||
|
||||
def account_filters_scope
|
||||
Status.not_excluded_by_account(account).tap do |scope|
|
||||
scope.merge!(Status.not_domain_blocked_by_account(account)) unless local_only?
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
# edited_at :datetime
|
||||
# trendable :boolean
|
||||
# ordered_media_attachment_ids :bigint(8) is an Array
|
||||
# searchability :integer
|
||||
#
|
||||
|
||||
require 'ostruct'
|
||||
|
@ -52,6 +53,7 @@ class Status < ApplicationRecord
|
|||
update_index('statuses', :proper)
|
||||
|
||||
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10 }, _suffix: :visibility
|
||||
enum searchability: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10 }, _suffix: :searchability
|
||||
|
||||
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
|
||||
|
||||
|
@ -115,6 +117,7 @@ class Status < ApplicationRecord
|
|||
scope :tagged_with_none, lambda { |tag_ids|
|
||||
where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
|
||||
}
|
||||
scope :unset_searchability, -> { where(searchability: nil, reblog_of_id: nil) }
|
||||
|
||||
cache_associated :application,
|
||||
:media_attachments,
|
||||
|
@ -255,6 +258,11 @@ class Status < ApplicationRecord
|
|||
ordered_media_attachments.any?
|
||||
end
|
||||
|
||||
def expired?
|
||||
false
|
||||
# !expired_at.nil?
|
||||
end
|
||||
|
||||
def with_preview_card?
|
||||
preview_cards.any?
|
||||
end
|
||||
|
@ -349,6 +357,10 @@ class Status < ApplicationRecord
|
|||
attributes['trendable'].nil? && account.requires_review_notification?
|
||||
end
|
||||
|
||||
def compute_searchability
|
||||
searchability || Status.searchabilities.invert.fetch([Account.searchabilities[account.searchability], Status.visibilities[visibility] || 0].max, nil) || 'direct'
|
||||
end
|
||||
|
||||
after_create_commit :increment_counter_caches
|
||||
after_destroy_commit :decrement_counter_caches
|
||||
|
||||
|
@ -358,6 +370,7 @@ class Status < ApplicationRecord
|
|||
before_validation :prepare_contents, if: :local?
|
||||
before_validation :set_reblog
|
||||
before_validation :set_visibility
|
||||
before_validation :set_searchability
|
||||
before_validation :set_conversation
|
||||
before_validation :set_local
|
||||
|
||||
|
@ -370,6 +383,10 @@ class Status < ApplicationRecord
|
|||
visibilities.keys - %w(direct limited)
|
||||
end
|
||||
|
||||
def selectable_searchabilities
|
||||
searchabilities.keys - %w(public_unlisted limited)
|
||||
end
|
||||
|
||||
def favourites_map(status_ids, account_id)
|
||||
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
|
||||
end
|
||||
|
@ -538,6 +555,12 @@ class Status < ApplicationRecord
|
|||
self.sensitive = false if sensitive.nil?
|
||||
end
|
||||
|
||||
def set_searchability
|
||||
return if searchability.nil?
|
||||
|
||||
self.searchability = [Status.searchabilities[searchability], Status.visibilities[visibility == 'public_unlisted' ? 'public' : visibility]].max
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
self.thread = thread.reblog if thread&.reblog?
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
# webauthn_id :string
|
||||
# sign_up_ip :inet
|
||||
# role_id :bigint(8)
|
||||
# settings :text
|
||||
#
|
||||
|
||||
class User < ApplicationRecord
|
||||
|
@ -314,6 +315,10 @@ class User < ApplicationRecord
|
|||
settings.default_privacy || (account.locked? ? 'private' : 'public')
|
||||
end
|
||||
|
||||
def setting_default_searchability
|
||||
settings.default_searchability || 'public'
|
||||
end
|
||||
|
||||
def allows_report_emails?
|
||||
settings.notification_emails['report']
|
||||
end
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class StatusPolicy < ApplicationPolicy
|
||||
def initialize(current_account, record, preloaded_relations = {})
|
||||
def initialize(current_account, record, preloaded_relations = {}, preloaded_status_relations = {})
|
||||
super(current_account, record)
|
||||
|
||||
@preloaded_relations = preloaded_relations
|
||||
@preloaded_status_relations = preloaded_status_relations
|
||||
end
|
||||
|
||||
delegate :reply?, :expired?, to: :record
|
||||
|
||||
def show?
|
||||
return false if author.suspended?
|
||||
|
||||
|
@ -19,18 +22,6 @@ class StatusPolicy < ApplicationPolicy
|
|||
end
|
||||
end
|
||||
|
||||
def search?
|
||||
return false if author.suspended?
|
||||
|
||||
if requires_mention?
|
||||
owned? || mention_exists?
|
||||
elsif !public?
|
||||
owned? || following_author? || mention_exists?
|
||||
else
|
||||
current_account.nil? || (!author_blocking? && !author_blocking_domain?)
|
||||
end
|
||||
end
|
||||
|
||||
def reblog?
|
||||
!requires_mention? && (!private? || owned?) && show? && !blocking_author?
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
class InitialStatePresenter < ActiveModelSerializers::Model
|
||||
attributes :settings, :push_subscription, :token,
|
||||
:current_account, :admin, :owner, :text, :visibility,
|
||||
:current_account, :admin, :owner, :text, :visibility, :searchability,
|
||||
:disabled_account, :moved_to_account
|
||||
|
||||
def role
|
||||
|
|
|
@ -7,13 +7,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
context :security
|
||||
|
||||
context_extensions :manually_approves_followers, :featured, :also_known_as,
|
||||
:moved_to, :property_value, :discoverable, :olm, :suspended
|
||||
:moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by
|
||||
|
||||
attributes :id, :type, :following, :followers,
|
||||
:inbox, :outbox, :featured, :featured_tags,
|
||||
:preferred_username, :name, :summary,
|
||||
:url, :manually_approves_followers,
|
||||
:discoverable, :published
|
||||
:discoverable, :published, :searchable_by
|
||||
|
||||
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
|
||||
|
||||
|
@ -162,6 +162,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||
object.created_at.midnight.iso8601
|
||||
end
|
||||
|
||||
def searchable_by
|
||||
ActivityPub::TagManager.instance.account_searchable_by(object)
|
||||
end
|
||||
|
||||
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
|
||||
end
|
||||
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
include FormattingHelper
|
||||
|
||||
context_extensions :atom_uri, :conversation, :sensitive, :voters_count
|
||||
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :searchable_by
|
||||
|
||||
attributes :id, :type, :summary,
|
||||
:in_reply_to, :published, :url,
|
||||
:attributed_to, :to, :cc, :sensitive,
|
||||
:atom_uri, :in_reply_to_atom_uri,
|
||||
:conversation
|
||||
:conversation, :searchable_by
|
||||
|
||||
attribute :content
|
||||
attribute :content_map, if: :language?
|
||||
|
@ -138,6 +138,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
end
|
||||
end
|
||||
|
||||
def searchable_by
|
||||
ActivityPub::TagManager.instance.searchable_by(object)
|
||||
end
|
||||
|
||||
def local?
|
||||
object.account.local?
|
||||
end
|
||||
|
|
|
@ -69,10 +69,11 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
store = {}
|
||||
|
||||
if object.current_account
|
||||
store[:me] = object.current_account.id.to_s
|
||||
store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy
|
||||
store[:default_sensitive] = object.current_account.user.setting_default_sensitive
|
||||
store[:default_language] = object.current_account.user.preferred_posting_language
|
||||
store[:me] = object.current_account.id.to_s
|
||||
store[:default_privacy] = object.visibility || object.current_account.user.setting_default_privacy
|
||||
store[:default_searchability] = object.searchability || object.current_account.user.setting_default_searchability
|
||||
store[:default_sensitive] = object.current_account.user.setting_default_sensitive
|
||||
store[:default_language] = object.current_account.user.preferred_posting_language
|
||||
end
|
||||
|
||||
store[:text] = object.text if object.text
|
||||
|
|
|
@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||
include FormattingHelper
|
||||
|
||||
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
|
||||
:note, :url, :avatar, :avatar_static, :header, :header_static,
|
||||
:note, :url, :avatar, :avatar_static, :header, :header_static, :searchability,
|
||||
:followers_count, :following_count, :statuses_count, :last_status_at
|
||||
|
||||
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
|
||||
|
|
|
@ -100,6 +100,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
:visibility_public_unlisted,
|
||||
:enable_wide_emoji,
|
||||
:enable_wide_emoji_reaction,
|
||||
:searchability,
|
||||
]
|
||||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
|
|
|
@ -5,7 +5,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
|
||||
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
|
||||
:sensitive, :spoiler_text, :visibility, :language, :translatable,
|
||||
:uri, :url, :replies_count, :reblogs_count,
|
||||
:uri, :url, :replies_count, :reblogs_count, :searchability,
|
||||
:favourites_count, :emoji_reactions, :edited_at
|
||||
|
||||
attribute :favourited, if: :current_user?
|
||||
|
@ -65,6 +65,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
end
|
||||
end
|
||||
|
||||
def searchability
|
||||
object.compute_searchability
|
||||
end
|
||||
|
||||
def sensitive
|
||||
if current_user? && current_user.account_id == object.account_id
|
||||
object.sensitive
|
||||
|
|
|
@ -29,6 +29,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
@account ||= Account.find_remote(@username, @domain)
|
||||
@old_public_key = @account&.public_key
|
||||
@old_protocol = @account&.protocol
|
||||
@old_searchability = @account&.searchability
|
||||
@suspension_changed = false
|
||||
|
||||
if @account.nil?
|
||||
|
@ -112,6 +113,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
@account.fields = property_values || {}
|
||||
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
|
||||
@account.discoverable = @json['discoverable'] || false
|
||||
@account.searchability = searchability_from_audience
|
||||
end
|
||||
|
||||
def set_fetchable_key!
|
||||
|
@ -150,6 +152,10 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def after_searchability_change!
|
||||
SearchabilityUpdateWorker.perform_async(@account.id) if @account.statuses.unset_searchability.exists?
|
||||
end
|
||||
|
||||
def after_protocol_change!
|
||||
ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
|
||||
end
|
||||
|
@ -224,6 +230,24 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def audience_searchable_by
|
||||
return nil if @json['searchableBy'].nil?
|
||||
|
||||
@audience_searchable_by = 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)
|
||||
|
||||
|
@ -310,6 +334,10 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||
!@old_protocol.nil? && @old_protocol != @account.protocol
|
||||
end
|
||||
|
||||
def searchability_changed?
|
||||
!@old_searchability.nil? && @old_searchability != @account.searchability
|
||||
end
|
||||
|
||||
def process_tags
|
||||
return if @json['tag'].blank?
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ class PostStatusService < BaseService
|
|||
# @option [Status] :thread Optional status to reply to
|
||||
# @option [Boolean] :sensitive
|
||||
# @option [String] :visibility
|
||||
# @option [String] :searchability
|
||||
# @option [String] :spoiler_text
|
||||
# @option [String] :language
|
||||
# @option [String] :scheduled_at
|
||||
|
@ -66,12 +67,28 @@ class PostStatusService < BaseService
|
|||
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
|
||||
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
|
||||
@visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted) && @account.silenced?
|
||||
@searchability= searchability
|
||||
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
||||
@scheduled_at = nil if scheduled_in_the_past?
|
||||
rescue ArgumentError
|
||||
raise ActiveRecord::RecordInvalid
|
||||
end
|
||||
|
||||
def searchability
|
||||
case @options[:searchability]&.to_sym
|
||||
when :public
|
||||
case @visibility&.to_sym when :public, :public_unlisted then :public when :unlisted, :private then :private else :direct end
|
||||
when :unlisted
|
||||
case @visibility&.to_sym when :public, :public_unlisted, :unlisted then :unlisted when :private then :private else :direct end
|
||||
when :private
|
||||
case @visibility&.to_sym when :public, :public_unlisted, :unlisted, :private then :private else :direct end
|
||||
when nil
|
||||
@account.searchability
|
||||
else
|
||||
:direct
|
||||
end
|
||||
end
|
||||
|
||||
def process_status!
|
||||
@status = @account.statuses.new(status_attributes)
|
||||
process_mentions_service.call(@status, save_records: false)
|
||||
|
@ -196,6 +213,7 @@ class PostStatusService < BaseService
|
|||
sensitive: @sensitive,
|
||||
spoiler_text: @options[:spoiler_text] || '',
|
||||
visibility: @visibility,
|
||||
searchability: @searchability,
|
||||
language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale),
|
||||
application: @options[:application],
|
||||
rate_limit: @options[:with_rate_limit],
|
||||
|
|
|
@ -8,6 +8,7 @@ class SearchService < BaseService
|
|||
@limit = limit.to_i
|
||||
@offset = options[:type].blank? ? 0 : options[:offset].to_i
|
||||
@resolve = options[:resolve] || false
|
||||
@searchability = options[:searchability] || @account.user&.setting_default_searchability || 'private'
|
||||
|
||||
default_results.tap do |results|
|
||||
next if @query.blank? || @limit.zero?
|
||||
|
@ -35,11 +36,26 @@ class SearchService < BaseService
|
|||
end
|
||||
|
||||
def perform_statuses_search!
|
||||
# definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id })).order(id: :desc)
|
||||
definition = parsed_query.apply(StatusesIndex).order(id: :desc)
|
||||
privacy_definition = parsed_query.apply(StatusesIndex.filter(term: { searchable_by: @account.id }))
|
||||
|
||||
# 'private' searchability posts are NOT in here because it's already added at previous line.
|
||||
case @searchability
|
||||
when 'public'
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'public' }))
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(terms: { searchability: %w(unlisted) }).filter(terms: { account_id: following_account_ids })) unless following_account_ids.empty?
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'direct' }).filter(term: { account_id: @account.id }))
|
||||
when 'unlisted', 'private'
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(terms: { searchability: %w(public unlisted) }).filter(terms: { account_id: following_account_ids })) unless following_account_ids.empty?
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'direct' }).filter(term: { account_id: @account.id }))
|
||||
when 'direct'
|
||||
privacy_definition = privacy_definition.or(StatusesIndex.filter(term: { searchability: 'direct' }).filter(term: { account_id: @account.id }))
|
||||
end
|
||||
|
||||
definition = parsed_query.apply(StatusesIndex).order(id: :desc)
|
||||
definition = definition.filter(term: { account_id: @options[:account_id] }) if @options[:account_id].present?
|
||||
|
||||
definition = definition.and(privacy_definition)
|
||||
|
||||
if @options[:min_id].present? || @options[:max_id].present?
|
||||
range = {}
|
||||
range[:gt] = @options[:min_id].to_i if @options[:min_id].present?
|
||||
|
@ -50,9 +66,10 @@ class SearchService < BaseService
|
|||
results = definition.limit(@limit).offset(@offset).objects.compact
|
||||
account_ids = results.map(&:account_id)
|
||||
account_domains = results.map(&:account_domain)
|
||||
preloaded_relations = relations_map_for_account(@account, account_ids, account_domains)
|
||||
account_relations = relations_map_for_account(@account, account_ids, account_domains) # old name: preloaded_relations
|
||||
status_relations = relations_map_for_status(@account, results)
|
||||
|
||||
results.reject { |status| StatusFilter.new(status, @account, preloaded_relations).search_filtered? }
|
||||
results.reject { |status| StatusFilter.new(status, @account, account_relations, status_relations).filtered? }
|
||||
rescue Faraday::ConnectionFailed, Parslet::ParseFailed
|
||||
[]
|
||||
end
|
||||
|
@ -122,7 +139,28 @@ class SearchService < BaseService
|
|||
}
|
||||
end
|
||||
|
||||
def relations_map_for_status(account, statuses)
|
||||
presenter = StatusRelationshipsPresenter.new(statuses, account)
|
||||
{
|
||||
reblogs_map: presenter.reblogs_map,
|
||||
favourites_map: presenter.favourites_map,
|
||||
bookmarks_map: presenter.bookmarks_map,
|
||||
emoji_reactions_map: presenter.emoji_reactions_map,
|
||||
mutes_map: presenter.mutes_map,
|
||||
pins_map: presenter.pins_map,
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
|
|
27
app/services/searchability_update_service.rb
Normal file
27
app/services/searchability_update_service.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SearchabilityUpdateService < BaseService
|
||||
def call(account)
|
||||
statuses = account.statuses.unset_searchability
|
||||
|
||||
return unless statuses.exists?
|
||||
|
||||
ids = statuses.pluck(:id)
|
||||
|
||||
if account.public_searchability?
|
||||
statuses.update_all('searchability = CASE visibility WHEN 0 THEN 0 WHEN 10 THEN 0 WHEN 1 THEN 2 WHEN 2 THEN 2 ELSE 3 END, updated_at = CURRENT_TIMESTAMP')
|
||||
elsif account.unlisted_searchability?
|
||||
statuses.update_all('searchability = CASE visibility WHEN 0 THEN 1 WHEN 10 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 2 ELSE 3 END, updated_at = CURRENT_TIMESTAMP')
|
||||
elsif account.private_searchability?
|
||||
statuses.update_all('searchability = CASE WHEN visibility IN (0, 1, 2, 10) THEN 2 ELSE 3 END, updated_at = CURRENT_TIMESTAMP')
|
||||
else
|
||||
statuses.update_all('searchability = 3, updated_at = CURRENT_TIMESTAMP')
|
||||
end
|
||||
|
||||
return unless Chewy.enabled?
|
||||
|
||||
ids.each_slice(100) do |chunk_ids|
|
||||
StatusesIndex.import chunk_ids, update_fields: [:searchability]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -22,6 +22,9 @@
|
|||
.fields-group.fields-row__column.fields-row__column-6
|
||||
= f.input :setting_default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false
|
||||
|
||||
.fields-group
|
||||
= f.input :setting_default_searchability, collection: Status.selectable_searchabilities, wrapper: :with_label, include_blank: false, label_method: lambda { |searchability| safe_join([I18n.t("statuses.searchabilities.#{searchability}"), I18n.t("statuses.searchabilities.#{searchability}_long")], ' - ') }, required: false, hint: false
|
||||
|
||||
.fields-group
|
||||
= f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
|
||||
|
||||
|
|
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
|
|
@ -0,0 +1,5 @@
|
|||
class AddSearchabilityToStatuses < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :statuses, :searchability, :integer
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class AddSearchabilityToAccounts < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :accounts, :searchability, :integer, null: false, default: 0
|
||||
end
|
||||
end
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2023_03_20_234918) do
|
||||
ActiveRecord::Schema.define(version: 2023_04_05_121625) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -188,6 +188,7 @@ ActiveRecord::Schema.define(version: 2023_03_20_234918) do
|
|||
t.datetime "reviewed_at"
|
||||
t.datetime "requested_review_at"
|
||||
t.boolean "group_allow_private_message"
|
||||
t.integer "searchability", default: 0, null: false
|
||||
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
|
||||
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
|
||||
t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id", where: "(moved_to_account_id IS NOT NULL)"
|
||||
|
@ -969,6 +970,7 @@ ActiveRecord::Schema.define(version: 2023_03_20_234918) do
|
|||
t.datetime "edited_at"
|
||||
t.boolean "trendable"
|
||||
t.bigint "ordered_media_attachment_ids", array: true
|
||||
t.integer "searchability"
|
||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||
t.index ["account_id"], name: "index_statuses_on_account_id"
|
||||
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
|
||||
|
@ -1089,6 +1091,7 @@ ActiveRecord::Schema.define(version: 2023_03_20_234918) do
|
|||
t.inet "sign_up_ip"
|
||||
t.boolean "skip_sign_in_token"
|
||||
t.bigint "role_id"
|
||||
t.text "settings"
|
||||
t.index ["account_id"], name: "index_users_on_account_id"
|
||||
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
||||
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue