Add: #483 特定の公開範囲を無効にするオプション (#712)

* Add: #483 特定の公開範囲を無効にするオプション

* Fix test partically

* Complete
This commit is contained in:
KMY(雪あすか) 2024-04-18 12:40:18 +09:00 committed by GitHub
parent 7f9ec2e510
commit f79fb3adae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 177 additions and 59 deletions

View file

@ -19,6 +19,16 @@ class Settings::Preferences::BaseController < Settings::BaseController
end end
def user_params def user_params
original_user_params.tap do |params|
params[:settings_attributes]&.merge!(disabled_visibilities_params[:settings_attributes] || {})
end
end
def original_user_params
params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys) params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys)
end end
def disabled_visibilities_params
params.require(:user).permit(settings_attributes: { enabled_visibilities: [] })
end
end end

View file

@ -116,7 +116,7 @@ class ComposeForm extends ImmutablePureComponent {
const fulltext = this.getFulltextForCharacterCounting(); const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia) || (privacy === 'circle' && !isEditing && !circleId)); return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia) || (privacy === 'circle' && !isEditing && !circleId) || privacy === 'banned');
}; };
handleSubmit = (e) => { handleSubmit = (e) => {

View file

@ -9,6 +9,7 @@ import Overlay from 'react-overlays/Overlay';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react'; import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
import PublicUnlistedIcon from '@/material-icons/400-24px/cloud.svg?react'; import PublicUnlistedIcon from '@/material-icons/400-24px/cloud.svg?react';
import MutualIcon from '@/material-icons/400-24px/compare_arrows.svg?react'; import MutualIcon from '@/material-icons/400-24px/compare_arrows.svg?react';
import LoginIcon from '@/material-icons/400-24px/key.svg?react'; import LoginIcon from '@/material-icons/400-24px/key.svg?react';
@ -18,7 +19,7 @@ import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import LimitedIcon from '@/material-icons/400-24px/shield.svg?react'; import LimitedIcon from '@/material-icons/400-24px/shield.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { enableLoginPrivacy, enableLocalPrivacy, enablePublicPrivacy } from 'mastodon/initial_state'; import { enabledVisibilites } from 'mastodon/initial_state';
import { PrivacyDropdownMenu } from './privacy_dropdown_menu'; import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
@ -42,6 +43,8 @@ const messages = defineMessages({
reply_long: { id: 'privacy.reply.long', defaultMessage: 'Reply to limited post' }, reply_long: { id: 'privacy.reply.long', defaultMessage: 'Reply to limited post' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
banned_short: { id: 'privacy.banned.short', defaultMessage: 'No posting' },
banned_long: { id: 'privacy.banned.long', defaultMessage: 'All public range submissions are disabled. User settings need to be modified.' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' },
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' }, unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
}); });
@ -131,7 +134,12 @@ class PrivacyDropdown extends PureComponent {
UNSAFE_componentWillMount () { UNSAFE_componentWillMount () {
const { intl: { formatMessage } } = this.props; const { intl: { formatMessage } } = this.props;
this.options = [ this.dynamicOptions = [
{ icon: 'reply', iconComponent: ReplyIcon, value: 'reply', text: formatMessage(messages.reply_short), meta: formatMessage(messages.reply_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
{ icon: 'ban', iconComponent: BlockIcon, value: 'banned', text: formatMessage(messages.banned_short), meta: formatMessage(messages.banned_long) },
];
this.originalOptions = [
{ icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, { icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'cloud', iconComponent: PublicUnlistedIcon, value: 'public_unlisted', text: formatMessage(messages.public_unlisted_short), meta: formatMessage(messages.public_unlisted_long) }, { icon: 'cloud', iconComponent: PublicUnlistedIcon, value: 'public_unlisted', text: formatMessage(messages.public_unlisted_short), meta: formatMessage(messages.public_unlisted_long) },
{ icon: 'key', iconComponent: LoginIcon, value: 'login', text: formatMessage(messages.login_short), meta: formatMessage(messages.login_long) }, { icon: 'key', iconComponent: LoginIcon, value: 'login', text: formatMessage(messages.login_short), meta: formatMessage(messages.login_long) },
@ -139,31 +147,28 @@ class PrivacyDropdown extends PureComponent {
{ icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, { icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'exchange', iconComponent: MutualIcon, value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon }, { icon: 'exchange', iconComponent: MutualIcon, value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
{ icon: 'user-circle', iconComponent: CircleIcon, value: 'circle', text: formatMessage(messages.circle_short), meta: formatMessage(messages.circle_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon }, { icon: 'user-circle', iconComponent: CircleIcon, value: 'circle', text: formatMessage(messages.circle_short), meta: formatMessage(messages.circle_long), extra: formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
{ icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
...this.dynamicOptions,
]; ];
if (!this.props.noDirect) { this.options = [...this.originalOptions];
this.options.push(
{ icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, if (this.props.noDirect) {
); this.options = this.options.filter((opt) => opt.value !== 'direct');
} }
if (this.props.noLimited) { if (this.props.noLimited) {
this.options = this.options.filter((opt) => !['mutual', 'circle'].includes(opt.value)); this.options = this.options.filter((opt) => !['mutual', 'circle'].includes(opt.value));
} }
if (!enableLoginPrivacy) { if (enabledVisibilites) {
this.options = this.options.filter((opt) => opt.value !== 'login'); this.options = this.options.filter((opt) => enabledVisibilites.includes(opt.value));
} }
if (!enableLocalPrivacy) { if (this.options.length === 0) {
this.options = this.options.filter((opt) => opt.value !== 'public_unlisted'); this.options.push(this.dynamicOptions.find((opt) => opt.value === 'banned'));
this.props.onChange('banned');
} }
if (!enablePublicPrivacy) {
this.options = this.options.filter((opt) => opt.value !== 'public');
}
this.selectableOptions = [...this.options];
} }
setTargetRef = c => { setTargetRef = c => {
@ -183,18 +188,16 @@ class PrivacyDropdown extends PureComponent {
const { open, placement } = this.state; const { open, placement } = this.state;
if (replyToLimited) { if (replyToLimited) {
if (!this.selectableOptions.some((op) => op.value === 'reply')) { if (!this.options.some((op) => op.value === 'reply')) {
this.selectableOptions.unshift( this.options.unshift(this.dynamicOptions.find((opt) => opt.value === 'reply'));
{ icon: 'reply', iconComponent: ReplyIcon, value: 'reply', text: intl.formatMessage(messages.reply_short), meta: intl.formatMessage(messages.reply_long), extra: intl.formatMessage(messages.limited_short), extraIcomComponent: LimitedIcon },
);
} }
} else { } else {
if (this.selectableOptions.some((op) => op.value === 'reply')) { if (this.options.some((op) => op.value === 'reply')) {
this.selectableOptions = this.selectableOptions.filter((op) => op.value !== 'reply'); this.options = this.options.filter((op) => op.value !== 'reply');
} }
} }
const valueOption = this.selectableOptions.find(item => item.value === value) || this.selectableOptions[0]; const valueOption = (disabled ? this.originalOptions : this.options).find(item => item.value === value) || this.options[0];
return ( return (
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}> <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
@ -217,7 +220,7 @@ class PrivacyDropdown extends PureComponent {
<div {...props}> <div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}> <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<PrivacyDropdownMenu <PrivacyDropdownMenu
items={this.selectableOptions} items={this.options}
value={value} value={value}
onClose={this.handleClose} onClose={this.handleClose}
onChange={this.handleChange} onChange={this.handleChange}

View file

@ -14,7 +14,7 @@ import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/no_encryption.svg?react'; import LockOpenIcon from '@/material-icons/400-24px/no_encryption.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { enableLocalPrivacy } from 'mastodon/initial_state'; import { enabledVisibilites } from 'mastodon/initial_state';
const messages = defineMessages({ const messages = defineMessages({
@ -236,7 +236,7 @@ class SearchabilityDropdown extends PureComponent {
{ icon: 'at', iconComponent: AlternateEmailIcon, value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) }, { icon: 'at', iconComponent: AlternateEmailIcon, value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) },
]; ];
if (!enableLocalPrivacy) { if (!enabledVisibilites.includes('public_unlisted')) {
this.options = this.options.filter((opt) => opt.value !== 'public_unlisted'); this.options = this.options.filter((opt) => opt.value !== 'public_unlisted');
} }
} }

View file

@ -32,16 +32,14 @@
* @property {boolean=} delete_modal * @property {boolean=} delete_modal
* @property {boolean=} disable_swiping * @property {boolean=} disable_swiping
* @property {string=} disabled_account_id * @property {string=} disabled_account_id
* @property {string[]} enabled_visibilities
* @property {string} display_media * @property {string} display_media
* @property {boolean} display_media_expand * @property {boolean} display_media_expand
* @property {string} domain * @property {string} domain
* @property {string} dtl_tag * @property {string} dtl_tag
* @property {boolean} enable_emoji_reaction * @property {boolean} enable_emoji_reaction
* @property {boolean} enable_login_privacy
* @property {boolean} enable_local_privacy
* @property {boolean} enable_local_timeline * @property {boolean} enable_local_timeline
* @property {boolean} enable_dtl_menu * @property {boolean} enable_dtl_menu
* @property {boolean} enable_public_privacy
* @property {boolean=} expand_spoilers * @property {boolean=} expand_spoilers
* @property {string[]} featured_tags * @property {string[]} featured_tags
* @property {HideItemsDefinition[]} hide_items * @property {HideItemsDefinition[]} hide_items
@ -121,15 +119,13 @@ export const boostModal = getMeta('boost_modal');
export const deleteModal = getMeta('delete_modal'); export const deleteModal = getMeta('delete_modal');
export const disableSwiping = getMeta('disable_swiping'); export const disableSwiping = getMeta('disable_swiping');
export const disabledAccountId = getMeta('disabled_account_id'); export const disabledAccountId = getMeta('disabled_account_id');
export const enabledVisibilites = getMeta('enabled_visibilities');
export const displayMedia = getMeta('display_media'); export const displayMedia = getMeta('display_media');
export const displayMediaExpand = getMeta('display_media_expand'); export const displayMediaExpand = getMeta('display_media_expand');
export const domain = getMeta('domain'); export const domain = getMeta('domain');
export const dtlTag = getMeta('dtl_tag'); export const dtlTag = getMeta('dtl_tag');
export const enableEmojiReaction = getMeta('enable_emoji_reaction'); export const enableEmojiReaction = getMeta('enable_emoji_reaction');
export const enableLocalPrivacy = getMeta('enable_local_privacy');
export const enablePublicPrivacy = getMeta('enable_public_privacy');
export const enableLocalTimeline = getMeta('enable_local_timeline'); export const enableLocalTimeline = getMeta('enable_local_timeline');
export const enableLoginPrivacy = getMeta('enable_login_privacy');
export const enableDtlMenu = getMeta('enable_dtl_menu'); export const enableDtlMenu = getMeta('enable_dtl_menu');
export const expandSpoilers = getMeta('expand_spoilers'); export const expandSpoilers = getMeta('expand_spoilers');
export const featuredTags = getMeta('featured_tags') || []; export const featuredTags = getMeta('featured_tags') || [];

View file

@ -712,6 +712,8 @@
"poll.votes": "{votes, plural, one {# vote} other {# votes}}", "poll.votes": "{votes, plural, one {# vote} other {# votes}}",
"poll_button.add_poll": "Add a poll", "poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll", "poll_button.remove_poll": "Remove poll",
"privacy.banned.long": "All public range submissions are disabled. User settings need to be modified.",
"privacy.banned.short": "No posting",
"privacy.change": "Change post privacy", "privacy.change": "Change post privacy",
"privacy.circle.long": "Circle members only", "privacy.circle.long": "Circle members only",
"privacy.circle.short": "Circle", "privacy.circle.short": "Circle",

View file

@ -696,6 +696,8 @@
"poll.votes": "{votes}票", "poll.votes": "{votes}票",
"poll_button.add_poll": "アンケートを追加", "poll_button.add_poll": "アンケートを追加",
"poll_button.remove_poll": "アンケートを削除", "poll_button.remove_poll": "アンケートを削除",
"privacy.banned.long": "全ての公開範囲が、ユーザー設定により無効になっています",
"privacy.banned.short": "投稿不可",
"privacy.change": "公開範囲を変更", "privacy.change": "公開範囲を変更",
"privacy.circle.long": "サークルメンバーのみ閲覧可", "privacy.circle.long": "サークルメンバーのみ閲覧可",
"privacy.circle.short": "サークル (投稿時点)", "privacy.circle.short": "サークル (投稿時点)",

View file

@ -58,7 +58,7 @@ import {
import { REDRAFT } from '../actions/statuses'; import { REDRAFT } from '../actions/statuses';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { enableLocalPrivacy, enableLoginPrivacy, enablePublicPrivacy, me } from '../initial_state'; import { enabledVisibilites, me } from '../initial_state';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { uuid } from '../uuid'; import { uuid } from '../uuid';
@ -143,9 +143,6 @@ function clearAll(state) {
if (state.get('stay_privacy') && !state.get('in_reply_to')) { if (state.get('stay_privacy') && !state.get('in_reply_to')) {
map.set('default_privacy', state.get('privacy')); map.set('default_privacy', state.get('privacy'));
} }
if ((map.get('privacy') === 'login' && !enableLoginPrivacy) || (map.get('privacy') === 'public_unlisted' && !enableLocalPrivacy)) {
map.set('privacy', enablePublicPrivacy ? 'public' : 'unlisted');
}
if (!state.get('in_reply_to')) { if (!state.get('in_reply_to')) {
map.set('posted_on_this_session', true); map.set('posted_on_this_session', true);
} }
@ -153,7 +150,7 @@ function clearAll(state) {
map.set('limited_scope', null); map.set('limited_scope', null);
map.set('id', null); map.set('id', null);
map.set('in_reply_to', null); map.set('in_reply_to', null);
if (state.get('default_searchability') === 'public_unlisted' && !enableLocalPrivacy) { if (state.get('default_searchability') === 'public_unlisted' && !enabledVisibilites.includes('public_unlisted')) {
map.set('searchability', 'public'); map.set('searchability', 'public');
} else { } else {
map.set('searchability', state.get('default_searchability')); map.set('searchability', state.get('default_searchability'));
@ -163,6 +160,7 @@ function clearAll(state) {
map.update('media_attachments', list => list.clear()); map.update('media_attachments', list => list.clear());
map.set('poll', null); map.set('poll', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
normalizePrivacy(map);
}); });
} }
@ -243,6 +241,22 @@ const sortHashtagsByUse = (state, tags) => {
return sorted; return sorted;
}; };
const normalizePrivacy = (map, last) => {
if (!enabledVisibilites) {
return;
}
const current = map.get('privacy');
const invalid = !enabledVisibilites.includes(current);
if (invalid) {
if (enabledVisibilites.length > 0) {
const index = last ? enabledVisibilites.length - 1 : 0;
map.set('privacy', enabledVisibilites[index]);
}
}
};
const insertEmoji = (state, position, emojiData, needsSpace) => { const insertEmoji = (state, position, emojiData, needsSpace) => {
const oldText = state.get('text'); const oldText = state.get('text');
const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native; const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
@ -467,6 +481,8 @@ export default function compose(state = initialState, action) {
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
} }
normalizePrivacy(map);
}); });
case COMPOSE_SUBMIT_REQUEST: case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true); return state.set('is_submitting', true);
@ -532,6 +548,7 @@ export default function compose(state = initialState, action) {
return state.withMutations(map => { return state.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
map.set('privacy', 'direct'); map.set('privacy', 'direct');
normalizePrivacy(map, true);
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
@ -584,6 +601,7 @@ export default function compose(state = initialState, action) {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
map.set('in_reply_to', action.status.get('in_reply_to_id')); map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility_ex')); map.set('privacy', action.status.get('visibility_ex'));
normalizePrivacy(map);
map.set('reply_to_limited', action.status.get('limited_scope') === 'reply'); map.set('reply_to_limited', action.status.get('limited_scope') === 'reply');
map.set('limited_scope', null); map.set('limited_scope', null);
map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true))); map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true)));

View file

@ -105,13 +105,18 @@ code {
.overridden, .overridden,
.recommended, .recommended,
.not_recommended, .not_recommended {
.kmyblue {
position: absolute; position: absolute;
margin: 0 4px; margin: 0 4px;
margin-top: -2px; margin-top: -2px;
} }
} }
.kmyblue {
position: absolute;
margin: 0 4px;
margin-top: -2px;
}
} }
.row { .row {

View file

@ -27,10 +27,6 @@ module User::HasSettings
settings['reaction_deck'] settings['reaction_deck']
end end
def setting_enable_login_privacy
settings['web.enable_login_privacy']
end
def setting_enable_dtl_menu def setting_enable_dtl_menu
settings['web.enable_dtl_menu'] settings['web.enable_dtl_menu']
end end
@ -235,6 +231,14 @@ module User::HasSettings
settings['default_reblog_privacy'] || 'unset' settings['default_reblog_privacy'] || 'unset'
end end
def setting_enabled_visibilities
settings['enabled_visibilities']
end
def setting_disabled_visibilities
settings['disabled_visibilities']
end
def setting_default_searchability def setting_default_searchability
settings['default_searchability'] || 'direct' settings['default_searchability'] || 'direct'
end end

View file

@ -497,7 +497,11 @@ class Status < ApplicationRecord
class << self class << self
def selectable_visibilities def selectable_visibilities
vs = visibilities.keys - %w(direct limited) selectable_all_visibilities - %w(mutual circle reply direct)
end
def selectable_all_visibilities
vs = %w(public public_unlisted login unlisted private mutual circle reply direct)
vs -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility vs -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility
vs -= %w(public) unless Setting.enable_public_visibility vs -= %w(public) unless Setting.enable_public_visibility
vs vs

View file

@ -23,6 +23,7 @@ class UserSettings
setting :default_privacy, default: nil, in: %w(public public_unlisted login unlisted private) setting :default_privacy, default: nil, in: %w(public public_unlisted login unlisted private)
setting :stay_privacy, default: false setting :stay_privacy, default: false
setting :default_reblog_privacy, default: nil setting :default_reblog_privacy, default: nil
setting :disabled_visibilities, default: %w()
setting :default_searchability, default: :direct, in: %w(public private direct limited public_unlisted) setting :default_searchability, default: :direct, in: %w(public private direct limited public_unlisted)
setting :default_searchability_of_search, default: :public, in: %w(public private direct limited) setting :default_searchability_of_search, default: :public, in: %w(public private direct limited)
setting :use_public_index, default: true setting :use_public_index, default: true
@ -47,6 +48,7 @@ class UserSettings
setting_inverse_alias :show_statuses_count, :hide_statuses_count setting_inverse_alias :show_statuses_count, :hide_statuses_count
setting_inverse_alias :show_following_count, :hide_following_count setting_inverse_alias :show_following_count, :hide_following_count
setting_inverse_alias :show_followers_count, :hide_followers_count setting_inverse_alias :show_followers_count, :hide_followers_count
setting_inverse_array :enabled_visibilities, :disabled_visibilities, %w(public public_unlisted login unlisted private mutual circle reply personal direct)
namespace :web do namespace :web do
setting :advanced_layout, default: false setting :advanced_layout, default: false
@ -57,7 +59,6 @@ class UserSettings
setting :bookmark_category_needed, default: false setting :bookmark_category_needed, default: false
setting :disable_swiping, default: false setting :disable_swiping, default: false
setting :delete_modal, default: true setting :delete_modal, default: true
setting :enable_login_privacy, default: false
setting :enable_dtl_menu, default: false setting :enable_dtl_menu, default: false
setting :hide_recent_emojis, default: false setting :hide_recent_emojis, default: false
setting :enable_emoji_reaction, default: true setting :enable_emoji_reaction, default: true

View file

@ -14,6 +14,10 @@ module UserSettings::DSL
@definitions[key] = @definitions[original_key].inverse_of(key) @definitions[key] = @definitions[original_key].inverse_of(key)
end end
def setting_inverse_array(key, original_key, reverse_array)
@definitions[key] = @definitions[original_key].array_inverse_of(key, reverse_array)
end
def namespace(key, &block) def namespace(key, &block)
@definitions ||= {} @definitions ||= {}

View file

@ -22,4 +22,8 @@ class UserSettings::Namespace
def setting_inverse_alias(key, original_key) def setting_inverse_alias(key, original_key)
@definitions[key] = @definitions[original_key].inverse_of(key) @definitions[key] = @definitions[original_key].inverse_of(key)
end end
def setting_inverse_array(key, original_key, reverse_array)
@definitions[key] = @definitions[original_key].array_inverse_of(key, reverse_array)
end
end end

View file

@ -15,6 +15,12 @@ class UserSettings::Setting
self self
end end
def array_inverse_of(name, arr)
@inverse_of_array = name.to_sym
@reverse_array = arr
self
end
def value_for(name, original_value) def value_for(name, original_value)
value = begin value = begin
if original_value.nil? if original_value.nil?
@ -24,13 +30,21 @@ class UserSettings::Setting
end end
end end
value = value.compact_blank if value.is_a?(Array)
if !@inverse_of.nil? && @inverse_of == name.to_sym if !@inverse_of.nil? && @inverse_of == name.to_sym
!value !value
elsif !@inverse_of_array.nil? && @inverse_of_array == name.to_sym
reverse_array(value)
else else
value value
end end
end end
def reverse_array(value)
@reverse_array.clone.filter { |v| value.exclude?(v) }
end
def default_value def default_value
if @default_value.respond_to?(:call) if @default_value.respond_to?(:call)
@default_value.call @default_value.call
@ -39,7 +53,13 @@ class UserSettings::Setting
end end
end end
def array_type?
default_value.is_a?(Array) || default_value == []
end
def type def type
return ActiveRecord::Type.lookup(:string, array: true) if array_type?
case default_value case default_value
when TrueClass, FalseClass when TrueClass, FalseClass
ActiveModel::Type::Boolean.new ActiveModel::Type::Boolean.new

View file

@ -26,7 +26,6 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:display_media_expand] = object_account_user.setting_display_media_expand store[:display_media_expand] = object_account_user.setting_display_media_expand
store[:expand_spoilers] = object_account_user.setting_expand_spoilers store[:expand_spoilers] = object_account_user.setting_expand_spoilers
store[:enable_emoji_reaction] = object_account_user.setting_enable_emoji_reaction && Setting.enable_emoji_reaction store[:enable_emoji_reaction] = object_account_user.setting_enable_emoji_reaction && Setting.enable_emoji_reaction
store[:enable_login_privacy] = object_account_user.setting_enable_login_privacy
store[:enable_dtl_menu] = object_account_user.setting_enable_dtl_menu store[:enable_dtl_menu] = object_account_user.setting_enable_dtl_menu
store[:reduce_motion] = object_account_user.setting_reduce_motion store[:reduce_motion] = object_account_user.setting_reduce_motion
store[:disable_swiping] = object_account_user.setting_disable_swiping store[:disable_swiping] = object_account_user.setting_disable_swiping
@ -50,6 +49,7 @@ class InitialStateSerializer < ActiveModel::Serializer
object_account_user.setting_show_quote_in_public ? nil : 'quote_in_public', object_account_user.setting_show_quote_in_public ? nil : 'quote_in_public',
object_account_user.setting_show_relationships ? nil : 'relationships', object_account_user.setting_show_relationships ? nil : 'relationships',
].compact ].compact
store[:enabled_visibilities] = enabled_visibilities
store[:featured_tags] = object.current_account.featured_tags.pluck(:name) store[:featured_tags] = object.current_account.featured_tags.pluck(:name)
else else
store[:auto_play_gif] = Setting.auto_play_gif store[:auto_play_gif] = Setting.auto_play_gif
@ -112,6 +112,13 @@ class InitialStateSerializer < ActiveModel::Serializer
LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] } LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] }
end end
def enabled_visibilities
vs = object_account_user.setting_enabled_visibilities
vs -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility
vs -= %w(public) unless Setting.enable_public_visibility
vs
end
private private
def default_meta_store def default_meta_store
@ -121,8 +128,6 @@ class InitialStateSerializer < ActiveModel::Serializer
admin: object.admin&.id&.to_s, admin: object.admin&.id&.to_s,
domain: Addressable::IDNA.to_unicode(instance_presenter.domain), domain: Addressable::IDNA.to_unicode(instance_presenter.domain),
dtl_tag: dtl_enabled? ? dtl_tag_name : nil, dtl_tag: dtl_enabled? ? dtl_tag_name : nil,
enable_local_privacy: Setting.enable_public_unlisted_visibility,
enable_public_privacy: Setting.enable_public_visibility,
enable_local_timeline: Setting.enable_local_timeline, enable_local_timeline: Setting.enable_local_timeline,
limited_federation_mode: Rails.configuration.x.limited_federation_mode, limited_federation_mode: Rails.configuration.x.limited_federation_mode,
locale: I18n.locale, locale: I18n.locale,

View file

@ -71,11 +71,7 @@ class PostStatusService < BaseService
def preprocess_attributes! def preprocess_attributes!
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present? @sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@visibility = @options[:visibility]&.to_sym || @account.user&.setting_default_privacy&.to_sym @visibility = visibility
@visibility = :limited if %w(mutual circle reply).include?(@options[:visibility])
@visibility = :unlisted if (@visibility == :public || @visibility == :public_unlisted || @visibility == :login) && @account.silenced?
@visibility = :public_unlisted if @visibility == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility
@visibility = Setting.enable_public_unlisted_visibility ? :public_unlisted : :unlisted if !Setting.enable_public_visibility && @visibility == :public
@limited_scope = @options[:visibility]&.to_sym if @visibility == :limited && @options[:visibility] != 'limited' @limited_scope = @options[:visibility]&.to_sym if @visibility == :limited && @options[:visibility] != 'limited'
@searchability = searchability @searchability = searchability
@searchability = :private if @account.silenced? && %i(public public_unlisted).include?(@searchability&.to_sym) @searchability = :private if @account.silenced? && %i(public public_unlisted).include?(@searchability&.to_sym)
@ -84,6 +80,7 @@ class PostStatusService < BaseService
@scheduled_at = nil if scheduled_in_the_past? @scheduled_at = nil if scheduled_in_the_past?
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?) @reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
raise ArgumentError if !Setting.enable_public_unlisted_visibility && (@visibility == :public_unlisted || @searchability == :public_unlisted) raise ArgumentError if !Setting.enable_public_unlisted_visibility && (@visibility == :public_unlisted || @searchability == :public_unlisted)
raise ArgumentError if @account.user&.setting_disabled_visibilities&.include?((@limited_scope || @visibility).to_s)
if @in_reply_to.present? && ((@options[:visibility] == 'limited' && @options[:circle_id].nil?) || @limited_scope == :reply) if @in_reply_to.present? && ((@options[:visibility] == 'limited' && @options[:circle_id].nil?) || @limited_scope == :reply)
@visibility = :limited @visibility = :limited
@ -97,6 +94,15 @@ class PostStatusService < BaseService
raise ActiveRecord::RecordInvalid raise ActiveRecord::RecordInvalid
end end
def visibility
v = @options[:visibility]&.to_sym || @account.user&.setting_default_privacy&.to_sym
v = :limited if %w(mutual circle reply).include?(@options[:visibility])
v = :unlisted if %i(public public_unlisted login).include?(v) && @account.silenced?
v = :public_unlisted if v == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility
v = Setting.enable_public_unlisted_visibility ? :public_unlisted : :unlisted if !Setting.enable_public_visibility && v == :public
v
end
def load_circle def load_circle
return if @visibility == :limited && @limited_scope == :reply && @in_reply_to.present? return if @visibility == :limited && @limited_scope == :reply && @in_reply_to.present?
return unless %w(circle limited reply).include?(@options[:visibility]) return unless %w(circle limited reply).include?(@options[:visibility])

View file

@ -40,7 +40,18 @@
= ff.input :public_post_to_unlisted, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_public_post_to_unlisted'), hint: I18n.t('simple_form.hints.defaults.setting_public_post_to_unlisted') = ff.input :public_post_to_unlisted, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_public_post_to_unlisted'), hint: I18n.t('simple_form.hints.defaults.setting_public_post_to_unlisted')
.fields-group .fields-group
= ff.input :'web.enable_login_privacy', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_enable_login_privacy'), hint: false = ff.input :enabled_visibilities,
as: :check_boxes,
collection_wrapper_tag: 'ul',
collection: Status.selectable_all_visibilities,
hint: I18n.t('simple_form.hints.defaults.setting_enabled_visibilities'),
include_blank: false,
item_wrapper_tag: 'li',
kmyblue: true,
label: I18n.t('simple_form.labels.defaults.setting_enabled_visibilities'),
label_method: ->(visibility) { I18n.t("statuses.visibilities.#{visibility}") },
required: false,
wrapper: :with_block_label
%h4= t 'preferences.searchability' %h4= t 'preferences.searchability'

View file

@ -135,6 +135,7 @@ SimpleForm.setup do |config|
config.wrappers :with_block_label, class: [:input, :with_block_label], hint_class: :field_with_hint, error_class: :field_with_errors do |b| config.wrappers :with_block_label, class: [:input, :with_block_label], hint_class: :field_with_hint, error_class: :field_with_errors do |b|
b.use :html5 b.use :html5
b.optional :kmyblue
b.use :label b.use :label
b.use :warning_hint, wrap_with: { tag: :span, class: [:hint, 'warning-hint'] } b.use :warning_hint, wrap_with: { tag: :span, class: [:hint, 'warning-hint'] }
b.use :hint, wrap_with: { tag: :span, class: :hint } b.use :hint, wrap_with: { tag: :span, class: :hint }

View file

@ -2084,17 +2084,20 @@ en:
too_many_mentions: Too many mentions too_many_mentions: Too many mentions
violate_rules: Violate NG rules violate_rules: Violate NG rules
visibilities: visibilities:
circle: Circle-only
direct: Direct direct: Direct
limited: Limited limited: Limited
login: Login-only login: Login-only
login_long: Only logined users login_long: Only logined users
mutual: Mutual-only
private: Followers-only private: Followers-only
private_long: Only show to followers private_long: Only show to followers
public: Public public: Public
public_long: Everyone can see public_long: Everyone can see
public_unlisted: Local-public public_unlisted: Local-public
public_unlisted_long: Everyone can see, but not listed on public timelines of other servers public_unlisted_long: Everyone can see, but not listed on public timelines of other servers
unlisted: Unlisted reply: Reply
unlisted: Quiet Public (Unlisted)
unlisted_long: Everyone can see, but not listed on public timelines unlisted_long: Everyone can see, but not listed on public timelines
unset: Default unset: Default
unset_long: Following Mastodon default behavior unset_long: Following Mastodon default behavior

View file

@ -2062,17 +2062,20 @@ ja:
too_many_mentions: メンションが多すぎます too_many_mentions: メンションが多すぎます
violate_rules: サーバールールに違反するため投稿できません violate_rules: サーバールールに違反するため投稿できません
visibilities: visibilities:
circle: サークル限定
direct: ダイレクト direct: ダイレクト
limited: 限定投稿 limited: 限定投稿
login: ログインユーザーのみ login: ログインユーザーのみ
login_long: ログインしたユーザーのみが見ることができます login_long: ログインしたユーザーのみが見ることができます
mutual: 相互限定
private: フォロワー限定 private: フォロワー限定
private_long: フォロワーにのみ表示されます private_long: フォロワーにのみ表示されます
public: 公開 public: 公開
public_long: 誰でも見ることができ、かつ公開タイムラインに表示されます public_long: 誰でも見ることができ、かつ公開タイムラインに表示されます
public_unlisted: ローカル公開 public_unlisted: ローカル公開
public_unlisted_long: 誰でも見ることができますが、他のサーバーの連合タイムラインには表示されません public_unlisted_long: 誰でも見ることができますが、他のサーバーの連合タイムラインには表示されません
unlisted: 非収載 reply: 返信
unlisted: ひかえめな公開 (非収載)
unlisted_long: 誰でも見ることができますが、公開タイムラインには表示されません unlisted_long: 誰でも見ることができますが、公開タイムラインには表示されません
unset: 設定なし unset: 設定なし
unset_long: デフォルトの挙動に従います unset_long: デフォルトの挙動に従います

View file

@ -71,6 +71,7 @@ en:
setting_dtl_force_visibility: "With using #%{tag} tag, your post settings will be changed forcibly" setting_dtl_force_visibility: "With using #%{tag} tag, your post settings will be changed forcibly"
setting_emoji_reaction_policy: Even with this setting, users on other servers are free to put their emoji reaction on the post and share it within the same server. If you simply want to remove the emoji reaction from your own screen, you can disable it from the appearance settings setting_emoji_reaction_policy: Even with this setting, users on other servers are free to put their emoji reaction on the post and share it within the same server. If you simply want to remove the emoji reaction from your own screen, you can disable it from the appearance settings
setting_enable_emoji_reaction: If turn off, other users still can react your posts setting_enable_emoji_reaction: If turn off, other users still can react your posts
setting_enabled_visibilities: If turn off, you cannot select and post the privacy.
setting_reject_send_limited_to_suspects: This applies to "Mutual Only" posts. Circle posts will be delivered without exception. Some Misskey servers have independently supported limited posting, but this is a setting for those who are concerned about it, as mutual-only posting exposes some of the users you are mutual with to Misskey users! setting_reject_send_limited_to_suspects: This applies to "Mutual Only" posts. Circle posts will be delivered without exception. Some Misskey servers have independently supported limited posting, but this is a setting for those who are concerned about it, as mutual-only posting exposes some of the users you are mutual with to Misskey users!
setting_reject_unlisted_subscription: Misskey and its forks can **subscribe and search** for "non-following" posts from accounts they do not follow. This differs from kmyblue's behavior. It delivers posts in the specified public range to such servers as "followers only". Please understand, however, that due to its structure, it is difficult to handle perfectly and will occasionally be delivered as non-subscribed. setting_reject_unlisted_subscription: Misskey and its forks can **subscribe and search** for "non-following" posts from accounts they do not follow. This differs from kmyblue's behavior. It delivers posts in the specified public range to such servers as "followers only". Please understand, however, that due to its structure, it is difficult to handle perfectly and will occasionally be delivered as non-subscribed.
setting_reverse_search_quote: Double-quotes will result in a search with a wider range of notation, which is the opposite of Mastodon's default behavior. setting_reverse_search_quote: Double-quotes will result in a search with a wider range of notation, which is the opposite of Mastodon's default behavior.
@ -268,7 +269,7 @@ en:
setting_dtl_menu: Show DTL menu on web setting_dtl_menu: Show DTL menu on web
setting_emoji_reaction_streaming_notify_impl2: Enable emoji reaction notification compat with Nyastodon, Catstodon, glitch-soc setting_emoji_reaction_streaming_notify_impl2: Enable emoji reaction notification compat with Nyastodon, Catstodon, glitch-soc
setting_enable_emoji_reaction: Use emoji reaction function setting_enable_emoji_reaction: Use emoji reaction function
setting_enable_login_privacy: Enable login visibility setting_enabled_visibilities: Enabled visibilities
setting_emoji_reaction_policy: Emoji reaction receive/display policy setting_emoji_reaction_policy: Emoji reaction receive/display policy
setting_emoji_reaction_policy_items: setting_emoji_reaction_policy_items:
allow: Allow all allow: Allow all

View file

@ -75,6 +75,7 @@ ja:
setting_emoji_reaction_policy: この設定をしても他のサーバーのユーザーはその投稿に自由に絵文字をつけ、同じサーバーの中で共有できます。単にあなた自身の画面から絵文字リアクションを除去したいだけなら、外観設定から絵文字リアクションを無効にすることができます setting_emoji_reaction_policy: この設定をしても他のサーバーのユーザーはその投稿に自由に絵文字をつけ、同じサーバーの中で共有できます。単にあなた自身の画面から絵文字リアクションを除去したいだけなら、外観設定から絵文字リアクションを無効にすることができます
setting_emoji_reaction_streaming_notify_impl2: 当該サーバーの独自機能に対応したアプリを利用時に、絵文字リアクション機能を利用できます。動作確認していないため(そもそもそのようなアプリ自体を確認できていないため)正しく動かない場合があります setting_emoji_reaction_streaming_notify_impl2: 当該サーバーの独自機能に対応したアプリを利用時に、絵文字リアクション機能を利用できます。動作確認していないため(そもそもそのようなアプリ自体を確認できていないため)正しく動かない場合があります
setting_enable_emoji_reaction: この機能を無効にしても、他の人はあなたの投稿に絵文字をつけられます setting_enable_emoji_reaction: この機能を無効にしても、他の人はあなたの投稿に絵文字をつけられます
setting_enabled_visibilities: チェックを外した公開範囲は投稿することができなくなります。投稿しようとするとエラーが出ます
setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします
setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります
setting_reject_send_limited_to_suspects: これは「相互のみ」投稿に適用されます。サークル投稿は例外なく配送されます。一部のMisskeyサーバーが独自に限定投稿へ対応しましたが、相互のみ投稿を行うとあなたと相互になっているユーザーの一部がMisskeyのユーザーに公開されるため、気になる人向けの設定です setting_reject_send_limited_to_suspects: これは「相互のみ」投稿に適用されます。サークル投稿は例外なく配送されます。一部のMisskeyサーバーが独自に限定投稿へ対応しましたが、相互のみ投稿を行うとあなたと相互になっているユーザーの一部がMisskeyのユーザーに公開されるため、気になる人向けの設定です
@ -273,7 +274,7 @@ ja:
setting_dtl_force_searchability: DTL投稿の検索許可 setting_dtl_force_searchability: DTL投稿の検索許可
setting_dtl_force_visibility: DTL投稿の公開範囲 setting_dtl_force_visibility: DTL投稿の公開範囲
setting_dtl_menu: Webクライアントのメニューにディープタイムラインを追加する setting_dtl_menu: Webクライアントのメニューにディープタイムラインを追加する
setting_enable_login_privacy: 公開範囲「ログインユーザーのみ」をWeb UIで選択可能にする setting_enabled_visibilities: 投稿を有効にする公開範囲
setting_emoji_reaction_policy: 絵文字リアクション受け入れと表示設定 setting_emoji_reaction_policy: 絵文字リアクション受け入れと表示設定
setting_emoji_reaction_policy_items: setting_emoji_reaction_policy_items:
allow: 全員に許可 allow: 全員に許可

View file

@ -192,6 +192,20 @@ RSpec.describe PostStatusService do
expect(mention_service).to have_received(:call).with(status, limited_type: '', circle: nil, save_records: false) expect(mention_service).to have_received(:call).with(status, limited_type: '', circle: nil, save_records: false)
end end
it 'self-banned visibility is set' do
user = Fabricate(:user)
user.settings.update(disabled_visibilities: ['public_unlisted'])
expect { subject.call(user.account, text: 'text', visibility: 'public_unlisted') }.to raise_error ActiveRecord::RecordInvalid
end
it 'self-banned visibility is not set' do
user = Fabricate(:user)
user.settings.update(disabled_visibilities: ['public_unlisted'])
expect { subject.call(user.account, text: 'text', visibility: 'unlisted') }.to_not raise_error
end
context 'with mutual visibility' do context 'with mutual visibility' do
let(:sender) { Fabricate(:user).account } let(:sender) { Fabricate(:user).account }
let(:io_account) { Fabricate(:account, domain: 'misskey.io', uri: 'https://misskey.io/actor') } let(:io_account) { Fabricate(:account, domain: 'misskey.io', uri: 'https://misskey.io/actor') }