Add circle visibility

This commit is contained in:
KMY 2023-08-21 18:22:14 +09:00
parent 44eb57183e
commit 3af223275f
20 changed files with 154 additions and 12 deletions

View file

@ -67,6 +67,7 @@ class Api::V1::StatusesController < Api::BaseController
visibility: status_params[:visibility],
force_visibility: status_params[:force_visibility],
searchability: status_params[:searchability],
circle_id: status_params[:circle_id],
language: status_params[:language],
scheduled_at: status_params[:scheduled_at],
application: doorkeeper_token.application,
@ -144,6 +145,7 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:force_visibility,
:searchability,
:circle_id,
:language,
:markdown,
:scheduled_at,

View file

@ -75,6 +75,8 @@ export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
export const COMPOSE_CIRCLE_CHANGE = 'COMPOSE_CIRCLE_CHANGE';
export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
@ -211,6 +213,7 @@ export function submitCompose(routerHistory) {
markdown: getState().getIn(['compose', 'markdown']),
visibility: getState().getIn(['compose', 'privacy']),
searchability: getState().getIn(['compose', 'searchability']),
circle_id: getState().getIn(['compose', 'circle_id']),
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
},
@ -837,3 +840,10 @@ export function changePollSettings(expiresIn, isMultiple) {
isMultiple,
};
}
export function changeCircle(circleId) {
return {
type: COMPOSE_CIRCLE_CHANGE,
circleId,
};
}

View file

@ -72,6 +72,7 @@ const messages = defineMessages({
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});
@ -403,6 +404,7 @@ class Status extends ImmutablePureComponent {
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
};

View file

@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux';
import { ScrollContext } from 'react-router-scroll-4';
import { fetchCircles } from 'mastodon/actions/circles';
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { fetchReactionDeck } from 'mastodon/actions/reaction_deck';
import { hydrateStore } from 'mastodon/actions/store';
@ -27,6 +28,7 @@ store.dispatch(hydrateAction);
if (initialState.meta.me) {
store.dispatch(fetchCustomEmojis());
store.dispatch(fetchReactionDeck());
store.dispatch(fetchCircles());
}
const createIdentityContext = state => ({

View file

@ -0,0 +1,59 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Select, { NonceProvider } from 'react-select';
class CircleSelect extends PureComponent {
static propTypes = {
unavailable: PropTypes.bool,
intl: PropTypes.object.isRequired,
circles: ImmutablePropTypes.list,
circleId: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
handleClick = value => {
this.props.onChange(value.value);
};
noOptionsMessage = () => '';
render () {
const { unavailable, circles, circleId } = this.props;
if (unavailable) {
return null;
}
const listOptions = circles.toArray().filter((circle) => circle[1]).map((circle) => {
return { value: circle[1].get('id'), label: circle[1].get('title') };
});
const listValue = listOptions.find((opt) => opt.value === circleId);
return (
<div className='compose-form__circle-select'>
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='circles'>
<Select
value={listValue}
options={listOptions}
noOptionsMessage={this.noOptionsMessage}
onChange={this.handleClick}
className='column-content-select__container'
classNamePrefix='column-content-select'
name='circles'
defaultOptions
/>
</NonceProvider>
</div>
);
}
}
export default injectIntl(CircleSelect);

View file

@ -14,6 +14,7 @@ import { Icon } from 'mastodon/components/icon';
import AutosuggestInput from '../../../components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import Button from '../../../components/button';
import CircleSelectContainer from '../containers/circle_select_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import ExpirationDropdownContainer from '../containers/expiration_dropdown_container';
import LanguageDropdown from '../containers/language_dropdown_container';
@ -76,6 +77,7 @@ class ComposeForm extends ImmutablePureComponent {
isInReply: PropTypes.bool,
singleColumn: PropTypes.bool,
lang: PropTypes.string,
circleId: PropTypes.string,
};
static defaultProps = {
@ -101,11 +103,11 @@ class ComposeForm extends ImmutablePureComponent {
};
canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
const { isSubmitting, isChangingUpload, isUploading, anyMedia, privacy, circleId } = this.props;
const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia));
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia) || (privacy === 'circle' && !circleId));
};
handleSubmit = (e) => {
@ -317,6 +319,8 @@ class ComposeForm extends ImmutablePureComponent {
</div>
</div>
<CircleSelectContainer />
<div className='compose-form__publish'>
<div className='compose-form__publish-button-wrapper'>
<Button

View file

@ -26,6 +26,8 @@ const messages = defineMessages({
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual' },
mutual_long: { id: 'privacy.mutual.long', defaultMessage: 'Mutual follows only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle' },
circle_long: { id: 'privacy.circle.long', defaultMessage: 'Circle members only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
@ -235,6 +237,7 @@ class PrivacyDropdown extends PureComponent {
{ 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: 'exchange', value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long) },
{ icon: 'user-circle', value: 'circle', text: formatMessage(messages.circle_short), meta: formatMessage(messages.circle_long) },
{ icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
];
this.selectableOptions = [...this.options];

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { changeCircle } from '../../../actions/compose';
import CircleSelect from '../components/circle_select';
const mapStateToProps = state => ({
unavailable: state.getIn(['compose', 'privacy']) !== 'circle',
circles: state.get('circles'),
circleId: state.getIn(['compose', 'circle_id']),
});
const mapDispatchToProps = dispatch => ({
onChange (circleId) {
dispatch(changeCircle(circleId));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(CircleSelect);

View file

@ -30,6 +30,7 @@ const mapStateToProps = state => ({
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']),
circleId: state.getIn(['compose', 'circle_id']),
});
const mapDispatchToProps = (dispatch) => ({

View file

@ -11,10 +11,10 @@ import Warning from '../components/warning';
const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: ['public', 'public_unlisted', 'login'].indexOf(state.getIn(['compose', 'privacy'])) < 0 && state.getIn(['compose', 'searchability']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
hashtagWarning: ['public', 'public_unlisted', 'login'].includes(state.getIn(['compose', 'privacy'])) && state.getIn(['compose', 'searchability']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
searchabilityWarning: state.getIn(['compose', 'searchability']) === 'limited',
limitedPostWarning: state.getIn(['compose', 'privacy']) === 'mutual',
limitedPostWarning: ['mutual', 'circle'].includes(state.getIn(['compose', 'privacy'])),
});
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning, limitedPostWarning }) => {

View file

@ -22,6 +22,7 @@ const messages = defineMessages({
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});
@ -55,6 +56,7 @@ class StatusCheckBox extends PureComponent {
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
};

View file

@ -33,6 +33,7 @@ const messages = defineMessages({
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
searchability_private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
@ -256,6 +257,7 @@ class DetailedStatus extends ImmutablePureComponent {
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
};

View file

@ -29,6 +29,7 @@ const messages = defineMessages({
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});
@ -98,6 +99,7 @@ class BoostModal extends ImmutablePureComponent {
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
};

View file

@ -47,6 +47,7 @@ import {
COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_OPTION_REMOVE,
COMPOSE_POLL_SETTINGS_CHANGE,
COMPOSE_CIRCLE_CHANGE,
INIT_MEDIA_EDIT_MODAL,
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS,
@ -68,6 +69,7 @@ const initialState = ImmutableMap({
spoiler_text: '',
markdown: false,
privacy: null,
circle_id: null,
searchability: null,
id: null,
text: '',
@ -136,6 +138,7 @@ function clearAll(state) {
map.update('media_attachments', list => list.clear());
map.set('poll', null);
map.set('idempotencyKey', uuid());
map.set('circle_id', null);
});
}
@ -597,6 +600,8 @@ export default function compose(state = initialState, action) {
return state.updateIn(['poll', 'options'], options => options.delete(action.index));
case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_CIRCLE_CHANGE:
return state.set('circle_id', action.circleId);
case COMPOSE_LANGUAGE_CHANGE:
return state.set('language', action.language);
case COMPOSE_FOCUS:

View file

@ -87,8 +87,11 @@ class ActivityPub::Parser::StatusParser
end
def limited_scope
if @object['limitedScope'] == 'Mutual'
case @object['limitedScope']
when 'Mutual'
:mutual
when 'Circle'
:circle
else
:none
end

View file

@ -222,7 +222,11 @@ class ActivityPub::TagManager
end
def limited_scope(status)
status.mutual_limited? ? 'Mutual' : ''
if status.mutual_limited?
'Mutual'
else
status.circle_limited? ? 'Circle' : ''
end
end
def subscribable_by(account)

View file

@ -55,7 +55,7 @@ class Status < ApplicationRecord
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility
enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability
enum limited_scope: { none: 0, mutual: 1 }, _suffix: :limited
enum limited_scope: { none: 0, mutual: 1, circle: 2 }, _suffix: :limited
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true

View file

@ -119,6 +119,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:kmyblue_visibility_login,
:status_reference,
:visibility_mutual,
:visibility_limited,
:kmyblue_limited_scope,
:kmyblue_antenna,
]

View file

@ -75,20 +75,29 @@ class PostStatusService < BaseService
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
@visibility = :direct if @in_reply_to&.limited_visibility?
@visibility = :limited if @options[:visibility] == 'mutual'
@visibility = :limited if %w(mutual circle).include?(@options[:visibility])
@visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted || @visibility&.to_sym == :login) && @account.silenced?
@visibility = :public_unlisted if @visibility&.to_sym == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted
@limited_scope = @options[:visibility]&.to_sym if @visibility == :limited
@searchability = searchability
@searchability = :private if @account.silenced? && @searchability&.to_sym == :public
@markdown = @options[:markdown] || false
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
load_circle
process_sensitive_words
rescue ArgumentError
raise ActiveRecord::RecordInvalid
end
def load_circle
return unless @options[:visibility] == 'circle'
@circle = @options[:circle_id].present? && Circle.find(@options[:circle_id])
raise ArgumentError if @circle.nil?
end
def process_sensitive_words
if [:public, :public_unlisted, :login].include?(@visibility&.to_sym) && Admin::SensitiveWord.sensitive?(@text, @options[:spoiler_text] || '')
@text = Admin::SensitiveWord.modified_text(@text, @options[:spoiler_text])
@ -115,7 +124,7 @@ class PostStatusService < BaseService
def process_status!
@status = @account.statuses.new(status_attributes)
process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? 'mutual' : '', save_records: false)
process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? @limited_scope : '', circle: @circle, save_records: false)
safeguard_mentions!(@status)
UpdateStatusExpirationService.new.call(@status)
@ -248,7 +257,7 @@ class PostStatusService < BaseService
spoiler_text: @options[:spoiler_text] || '',
markdown: @markdown,
visibility: @visibility,
limited_scope: @visibility == :limited ? :mutual : :none,
limited_scope: @limited_scope || :none,
searchability: @searchability,
language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale),
application: @options[:application],

View file

@ -7,9 +7,10 @@ class ProcessMentionsService < BaseService
# and create local mention pointers
# @param [Status] status
# @param [Boolean] save_records Whether to save records in database
def call(status, limited_type: '', save_records: true)
def call(status, limited_type: '', circle: nil, save_records: true)
@status = status
@limited_type = limited_type
@circle = circle
@save_records = save_records
return unless @status.local?
@ -63,7 +64,8 @@ class ProcessMentionsService < BaseService
"@#{mentioned_account.acct}"
end
process_mutual! if @limited_type == 'mutual'
process_mutual! if @limited_type == :mutual
process_circle! if @limited_type == :circle
@status.save! if @save_records
end
@ -103,4 +105,12 @@ class ProcessMentionsService < BaseService
@current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id)
end
end
def process_circle!
mentioned_account_ids = @current_mentions.map(&:account_id)
@circle.accounts.find_each do |target_account|
@current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id)
end
end
end