diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index 3b097a34780369..4345b61ac74ced 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -52,11 +52,11 @@ def set_filter end def resource_params - params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :whole_word, context: []) + params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :whole_word, context: []) end def filter_params - resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :context) + resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :context) end def keyword_params diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb index 4dc4bd92c8aa56..f437576d1b8abc 100644 --- a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb +++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb @@ -31,7 +31,7 @@ def destroy end render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new( - [@status], current_account.id, emoji_reactions_map: { @status.id => false } + [@status], current_account.id ) rescue Mastodon::NotPermittedError not_found diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb index f3e9938d8c2762..5e39d77416cf94 100644 --- a/app/controllers/api/v2/filters_controller.rb +++ b/app/controllers/api/v2/filters_controller.rb @@ -43,6 +43,6 @@ def set_filter end def resource_params - params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index b0b2168884eef8..9549ae3500eb85 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -49,7 +49,7 @@ def set_filter end def resource_params - params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end def set_body_classes diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 369be6b8fbc0ca..50b90ef655cd82 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -80,6 +80,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote && status.quote.id) { + processStatus(status.quote); + } + if (status.poll && status.poll.id) { pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 3220118f3d60ab..a376992b7ec012 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -85,6 +85,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + // for quoted post + if (!normalStatus.filtered && normalOldStatus.get('filtered')) { + normalStatus.filtered = normalOldStatus.get('filtered'); + } + if (normalOldStatus.get('translation')) { normalStatus.translation = normalOldStatus.get('translation'); } diff --git a/app/javascript/mastodon/components/compacted_status.jsx b/app/javascript/mastodon/components/compacted_status.jsx new file mode 100644 index 00000000000000..7ab5ee20258b64 --- /dev/null +++ b/app/javascript/mastodon/components/compacted_status.jsx @@ -0,0 +1,503 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +import { HotKeys } from 'react-hotkeys'; + +import AttachmentList from 'mastodon/components/attachment_list'; +import { Icon } from 'mastodon/components/icon'; + +import Card from '../features/status/components/card'; +// We use the component (and not the container) since we do not want +// to use the progress bar to show download progress +import Bundle from '../features/ui/components/bundle'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; +import { displayMedia } from '../initial_state'; + +import { Avatar } from './avatar'; +import { DisplayName } from './display_name'; +import { getHashtagBarForStatus } from './hashtag_bar'; +import { RelativeTimestamp } from './relative_timestamp'; +import StatusContent from './status_content'; + +const domParser = new DOMParser(); + +export const textForScreenReader = (intl, status, rebloggedByText = false) => { + const displayName = status.getIn(['account', 'display_name']); + + const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text'); + const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); + const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent; + + const values = [ + displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, + spoilerText && status.get('hidden') ? spoilerText : contentText, + intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), + status.getIn(['account', 'acct']), + ]; + + if (rebloggedByText) { + values.push(rebloggedByText); + } + + return values.join(', '); +}; + +export const defaultMediaVisibility = (status) => { + if (!status) { + return undefined; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); +}; + +const messages = defineMessages({ + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, + login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, + 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' }, + personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, + edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, +}); + +class CompactedStatus extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + previousId: PropTypes.string, + nextInReplyToId: PropTypes.string, + rootId: PropTypes.string, + onClick: PropTypes.func, + onOpenMedia: PropTypes.func, + onOpenVideo: PropTypes.func, + onHeightChange: PropTypes.func, + onToggleHidden: PropTypes.func, + onToggleCollapsed: PropTypes.func, + onTranslate: PropTypes.func, + onInteractionModal: PropTypes.func, + muted: PropTypes.bool, + hidden: PropTypes.bool, + unread: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, + showThread: PropTypes.bool, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + }; + + // Avoid checking props that are functions (and whose equality will always + // evaluate to false. See react-immutable-pure-component for usage. + updateOnProps = [ + 'status', + 'muted', + 'hidden', + 'unread', + ]; + + state = { + showMedia: defaultMediaVisibility(this.props.status), + statusId: undefined, + forceFilter: undefined, + }; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { + return { + showMedia: defaultMediaVisibility(nextProps.status), + statusId: nextProps.status.get('id'), + }; + } else { + return null; + } + } + + handleToggleMediaVisibility = () => { + this.setState({ showMedia: !this.state.showMedia }); + }; + + handleClick = e => { + if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { + return; + } + + if (e) { + e.preventDefault(); + } + + this.handleHotkeyOpen(); + }; + + handlePrependAccountClick = e => { + this.handleAccountClick(e, false); + }; + + handleAccountClick = (e, proper = true) => { + if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { + return; + } + + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + this._openProfile(proper); + }; + + handleExpandedToggle = () => { + this.props.onToggleHidden(this._properStatus()); + }; + + handleCollapsedToggle = isCollapsed => { + this.props.onToggleCollapsed(this._properStatus(), isCollapsed); + }; + + handleTranslate = () => { + this.props.onTranslate(this._properStatus()); + }; + + getAttachmentAspectRatio () { + const attachments = this._properStatus().get('media_attachments'); + + if (attachments.getIn([0, 'type']) === 'video') { + return `${attachments.getIn([0, 'meta', 'original', 'width'])} / ${attachments.getIn([0, 'meta', 'original', 'height'])}`; + } else if (attachments.getIn([0, 'type']) === 'audio') { + return '16 / 9'; + } else { + return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2' + } + } + + renderLoadingMediaGallery = () => { + return ( +
+ ); + }; + + renderLoadingVideoPlayer = () => { + return ( +
+ ); + }; + + renderLoadingAudioPlayer = () => { + return ( +
+ ); + }; + + handleOpenVideo = (options) => { + const status = this._properStatus(); + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options); + }; + + handleOpenMedia = (media, index) => { + const status = this._properStatus(); + const lang = status.getIn(['translation', 'language']) || status.get('language'); + this.props.onOpenMedia(status.get('id'), media, index, lang); + }; + + handleHotkeyOpenMedia = e => { + const { onOpenMedia, onOpenVideo } = this.props; + const status = this._properStatus(); + + e.preventDefault(); + + if (status.get('media_attachments').size > 0) { + const lang = status.getIn(['translation', 'language']) || status.get('language'); + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 }); + } else { + onOpenMedia(status.get('id'), status.get('media_attachments'), 0, lang); + } + } + }; + + handleHotkeyOpen = () => { + if (this.props.onClick) { + this.props.onClick(); + return; + } + + const { router } = this.context; + const status = this._properStatus(); + + if (!router) { + return; + } + + router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); + }; + + handleHotkeyOpenProfile = () => { + this._openProfile(); + }; + + _openProfile = (proper = true) => { + const { router } = this.context; + const status = proper ? this._properStatus() : this.props.status; + + if (!router) { + return; + } + + router.history.push(`/@${status.getIn(['account', 'acct'])}`); + }; + + handleHotkeyMoveUp = e => { + this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); + }; + + handleHotkeyMoveDown = e => { + this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); + }; + + handleHotkeyToggleHidden = () => { + this.props.onToggleHidden(this._properStatus()); + }; + + handleHotkeyToggleSensitive = () => { + this.handleToggleMediaVisibility(); + }; + + _properStatus () { + const { status } = this.props; + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + return status.get('reblog'); + } else { + return status; + } + } + + handleRef = c => { + this.node = c; + }; + + render () { + const { intl, hidden, featured, unread, showThread, previousId, nextInReplyToId, rootId } = this.props; + + let { status } = this.props; + + if (status === null) { + return null; + } + + const handlers = this.props.muted ? {} : { + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleHotkeyToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, + openMedia: this.handleHotkeyOpenMedia, + }; + + let media, isCardMediaWithSensitive, prepend, rebloggedByText; + + if (hidden) { + return ( + +
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {status.get('content')} +
+
+ ); + } + + const connectUp = previousId && previousId === status.get('in_reply_to_id'); + const connectToRoot = rootId && rootId === status.get('in_reply_to_id'); + const connectReply = nextInReplyToId && nextInReplyToId === status.get('id'); + + if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) { + const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; + + prepend = ( +
+
+ }} /> +
+ ); + } + + if (status.get('quote_muted')) { + const minHandlers = { + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; + + return ( + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ +
+
+ ); + } + + isCardMediaWithSensitive = false; + + if (status.get('media_attachments').size > 0) { + const language = status.getIn(['translation', 'language']) || status.get('language'); + + if (this.props.muted) { + media = ( + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media = ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); + + media = ( + + {Component => ( + + )} + + ); + } else { + media = ( + + {Component => ( + + )} + + ); + } + } else if (status.get('card') && !this.props.muted) { + media = ( + + ); + isCardMediaWithSensitive = status.get('spoiler_text').length > 0; + } + + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; + + return ( + +
+ {prepend} + +
+ {(connectReply || connectUp || connectToRoot) &&
} + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + + + + + {(!isCardMediaWithSensitive || !status.get('hidden')) && media} + + {(!status.get('spoiler_text') || expanded) && hashtagBar} +
+
+ + ); + } + +} + +export default injectIntl(CompactedStatus); diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index be2f457c76ceac..536842481f5386 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -236,6 +236,7 @@ class MediaGallery extends PureComponent { visible: PropTypes.bool, autoplay: PropTypes.bool, onToggleVisibility: PropTypes.func, + compact: PropTypes.bool, }; state = { @@ -306,7 +307,7 @@ class MediaGallery extends PureComponent { } render () { - const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props; + const { media, lang, intl, sensitive, defaultWidth, autoplay, compact } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -359,9 +360,10 @@ class MediaGallery extends PureComponent { const columnClass = (size === 9) ? 'media-gallery--column3' : (size === 10 || size === 11 || size === 12 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--column4' : 'media-gallery--column2'; + const compactClass = compact ? 'media-gallery__compact' : null; return ( -
+
{spoilerButton}
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 1a65aee51fb38f..88a921c780df33 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -13,12 +13,13 @@ import AttachmentList from 'mastodon/components/attachment_list'; import { Icon } from 'mastodon/components/icon'; import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; +import CompactedStatusContainer from '../containers/compacted_status_container' import Card from '../features/status/components/card'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; -import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline } from '../initial_state'; +import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline, showQuoteInHome, showQuoteInPublic } from '../initial_state'; import { Avatar } from './avatar'; import { AvatarOverlay } from './avatar_overlay'; @@ -87,6 +88,7 @@ class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, + contextType: PropTypes.string, previousId: PropTypes.string, nextInReplyToId: PropTypes.string, rootId: PropTypes.string, @@ -357,15 +359,17 @@ class Status extends ImmutablePureComponent { }; render () { - const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props; + const { intl, hidden, featured, unread, muted, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props; let { status, account, ...other } = this.props; + + const contextType = (this.props.contextType || '').split(':')[0]; if (status === null) { return null; } - const handlers = this.props.muted ? {} : { + const handlers = muted ? {} : { reply: this.handleHotkeyReply, favourite: this.handleHotkeyFavourite, boost: this.handleHotkeyBoost, @@ -384,7 +388,7 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( -
+
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')}
@@ -412,12 +416,53 @@ class Status extends ImmutablePureComponent { let visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; + if (account === undefined || account === null) { + statusAvatar = ; + } else { + statusAvatar = ; + } + if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { - const minHandlers = this.props.muted ? {} : { + const minHandlers = muted ? {} : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, }; + if (status.get('filter_action') === 'half_warn') { + return ( + +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + + +
+ : {matchedFilters.join(', ')}. + {' '} + +
+
+
+ ); + } + return (
@@ -478,7 +523,7 @@ class Status extends ImmutablePureComponent { } else if (status.get('media_attachments').size > 0) { const language = status.getIn(['translation', 'language']) || status.get('language'); - if (this.props.muted) { + if (muted) { media = ( ); } - } else if (status.get('card') && !this.props.muted) { + } else if (status.get('card') && !muted) { media = ( 0; } - if (account === undefined || account === null) { - statusAvatar = ; - } else { - statusAvatar = ; - } - visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; let emojiReactionsBar = null; @@ -588,20 +627,24 @@ class Status extends ImmutablePureComponent { const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0; const withLimited = status.get('visibility_ex') === 'limited' && status.get('limited_scope') ? : null; - const withReference = status.get('status_references_count') > 0 ? : null; + const withQuote = status.get('quote_id') ? : null; + const withReference = (!withQuote && status.get('status_references_count') > 0) ? : null; const withExpiration = status.get('expires_at') ? : null; + const quote = !muted && status.get('quote_id') && (['public', 'community'].includes(contextType) ? showQuoteInPublic : showQuoteInHome) && + return ( -
+
{prepend} -
+
{(connectReply || connectUp || connectToRoot) &&
} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+ {withQuote} {withReference} {withExpiration} {withLimited} @@ -629,6 +672,8 @@ class Status extends ImmutablePureComponent { {...statusContentProps} /> + {(!status.get('spoiler_text') || expanded) && quote} + {(!isCardMediaWithSensitive || !status.get('hidden')) && media} {(!status.get('spoiler_text') || expanded) && hashtagBar} diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index a6f5e870376501..8fef327039938b 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -298,6 +298,7 @@ class StatusActionBar extends ImmutablePureComponent { const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']); let menu = []; @@ -332,7 +333,10 @@ class StatusActionBar extends ImmutablePureComponent { if (publicStatus) { menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference }); - menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + + if (allowQuote) { + menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + } } menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClickOriginal }); diff --git a/app/javascript/mastodon/containers/compacted_status_container.jsx b/app/javascript/mastodon/containers/compacted_status_container.jsx new file mode 100644 index 00000000000000..8d483ed36f3ff3 --- /dev/null +++ b/app/javascript/mastodon/containers/compacted_status_container.jsx @@ -0,0 +1,78 @@ +import { injectIntl } from 'react-intl'; + +import { connect } from 'react-redux'; + +import { openModal } from '../actions/modal'; +import { + hideStatus, + revealStatus, + toggleStatusCollapse, + translateStatus, + undoStatusTranslation, +} from '../actions/statuses'; +import CompactedStatus from '../components/compacted_status'; +import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props), + nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null, + pictureInPicture: getPictureInPicture(state, props), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch) => ({ + + onTranslate (status) { + if (status.get('translation')) { + dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); + } else { + dispatch(translateStatus(status.get('id'))); + } + }, + + onOpenMedia (statusId, media, index, lang) { + dispatch(openModal({ + modalType: 'MEDIA', + modalProps: { statusId, media, index, lang }, + })); + }, + + onOpenVideo (statusId, media, lang, options) { + dispatch(openModal({ + modalType: 'VIDEO', + modalProps: { statusId, media, lang, options }, + })); + }, + + onToggleHidden (status) { + if (status.get('hidden')) { + dispatch(revealStatus(status.get('id'))); + } else { + dispatch(hideStatus(status.get('id'))); + } + }, + + onToggleCollapsed (status, isCollapsed) { + dispatch(toggleStatusCollapse(status.get('id'), isCollapsed)); + }, + + onInteractionModal (type, status) { + dispatch(openModal({ + modalType: 'INTERACTION', + modalProps: { + type, + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + })); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(CompactedStatus)); diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index da3058334b6ed1..4b79fc271ca155 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -80,6 +80,8 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ + contextType, + onReply (status, router) { dispatch((_, getState) => { let state = getState(); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 2e5df723dea114..3b258b60bbd45f 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -236,6 +236,7 @@ class ActionBar extends PureComponent { const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']); let menu = []; @@ -259,7 +260,10 @@ class ActionBar extends PureComponent { if (publicStatus) { menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference }); - menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + + if (allowQuote) { + menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote }); + } } menu.push({ text: intl.formatMessage(messages.bookmark_category), action: this.handleBookmarkCategoryAdderClick }); diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 91582714293639..0948e5d2559c31 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -64,6 +64,7 @@ * @property {boolean} enable_local_privacy * @property {boolean} enable_dtl_menu * @property {boolean=} expand_spoilers + * @property {boolean} hide_blocking_quote * @property {boolean} hide_recent_emojis * @property {boolean} limited_federation_mode * @property {string} locale @@ -78,6 +79,8 @@ * @property {boolean} search_enabled * @property {boolean} trends_enabled * @property {boolean} show_emoji_reaction_on_timeline + * @property {boolean} show_quote_in_home + * @property {boolean} show_quote_in_public * @property {string} simple_timeline_menu * @property {boolean} single_user_mode * @property {string} source_url @@ -136,6 +139,7 @@ export const enableLoginPrivacy = getMeta('enable_login_privacy'); export const enableDtlMenu = getMeta('enable_dtl_menu'); export const expandSpoilers = getMeta('expand_spoilers'); export const forceSingleColumn = !getMeta('advanced_layout'); +export const hideBlockingQuote = getMeta('hide_blocking_quote'); export const hideRecentEmojis = getMeta('hide_recent_emojis'); export const limitedFederationMode = getMeta('limited_federation_mode'); export const mascot = getMeta('mascot'); @@ -149,6 +153,8 @@ export const repository = getMeta('repository'); export const searchEnabled = getMeta('search_enabled'); export const trendsEnabled = getMeta('trends_enabled'); export const showEmojiReactionOnTimeline = getMeta('show_emoji_reaction_on_timeline'); +export const showQuoteInHome = getMeta('show_quote_in_home'); +export const showQuoteInPublic = getMeta('show_quote_in_public'); export const showTrends = getMeta('show_trends'); export const simpleTimelineMenu = getMeta('simple_timeline_menu'); export const singleUserMode = getMeta('single_user_mode'); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e553549508710b..780a5df5c8e5bc 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -692,7 +692,7 @@ "status.open": "Expand this post", "status.pin": "Pin on profile", "status.pinned": "Pinned post", - "status.quote": "Ref (quote in other servers)", + "status.quote": "Quote", "status.read_more": "Read more", "status.reblog": "Boost", "status.reblog_private": "Boost with original visibility", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index efe5842199f8f1..65fad31e219fe7 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -778,7 +778,7 @@ "status.open": "詳細を表示", "status.pin": "プロフィールに固定表示", "status.pinned": "固定された投稿", - "status.quote": "参照 (他サーバーで引用扱い)", + "status.quote": "引用", "status.read_more": "もっと見る", "status.reblog": "ブースト", "status.reblog_private": "ブースト", diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index f6398e93240600..9446b77c7fcb6e 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -3,7 +3,7 @@ import { createSelector } from 'reselect'; import { toServerSideType } from 'mastodon/utils/filters'; -import { me } from '../initial_state'; +import { me, hideBlockingQuote } from '../initial_state'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); @@ -53,7 +53,12 @@ export const makeGetStatus = () => { statusReblog = null; } + if (hideBlockingQuote && statusBase.getIn(['quote', 'quote_muted'])) { + return null; + } + let filtered = false; + let filterAction = 'warn'; if ((accountReblog || accountBase).get('id') !== me && filters) { let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList(); if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { @@ -62,6 +67,7 @@ export const makeGetStatus = () => { filterResults = filterResults.filter(result => filters.has(result.get('filter'))); if (!filterResults.isEmpty()) { filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title'])); + filterAction = filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'warn') ? 'warn' : 'half_warn'; } } @@ -69,6 +75,7 @@ export const makeGetStatus = () => { map.set('reblog', statusReblog); map.set('account', accountBase); map.set('matched_filters', filtered); + map.set('filter_action', filterAction); }); }, ); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 237387c50a2bda..95def262998ea1 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1733,6 +1733,11 @@ a.account__display-name { .status__avatar { width: 46px; height: 46px; + + &.status__avatar__compact { + width: 24px; + height: 24px; + } } .muted { @@ -6519,7 +6524,7 @@ a.status-card { position: relative; width: 100%; min-height: 64px; - max-height: 70vh; + max-height: 60vh; display: grid; grid-template-columns: 50% 50%; grid-template-rows: 50% 50%; @@ -6540,6 +6545,10 @@ a.status-card { &--column4 { grid-template-columns: 25% 25% 25% 25%; } + + &__compact { + max-height: 24vh; + } } .media-gallery__item { @@ -8479,6 +8488,13 @@ noscript { .status__wrapper { position: relative; + &.status__wrapper__compact { + border-radius: 4px; + border: 1px solid $ui-primary-color; + margin-block-start: 16px; + cursor: pointer; + } + &.unread { &::before { content: ''; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 1f81fcda6c53b1..6208130dad5bcf 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -444,7 +444,7 @@ def ignore_hashtags? def related_to_local_activity? fetch? || followed_by_local_accounts? || requested_through_relay? || - responds_to_followed_account? || addresses_local_accounts? + responds_to_followed_account? || addresses_local_accounts? || quote_local? end def responds_to_followed_account? @@ -485,10 +485,22 @@ def increment_voters_count! def process_references! references = @object['references'].nil? ? [] : ActivityPub::FetchReferencesService.new.call(@status, @object['references']) - quote = @object['quote'] || @object['quoteUrl'] || @object['quoteURL'] || @object['_misskey_quote'] - references << quote if quote - ProcessReferencesService.perform_worker_async(@status, [], references) + ProcessReferencesService.perform_worker_async(@status, [], references, [quote].compact) + end + + def quote_local? + url = quote + + if url.present? + ResolveURLService.new.call(url, on_behalf_of: @account, local_only: true).present? + else + false + end + end + + def quote + @quote ||= @object['quote'] || @object['quoteUrl'] || @object['quoteURL'] || @object['_misskey_quote'] end def join_group! diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb index 93f52bc7ec05bc..6ce014395361ad 100644 --- a/app/lib/status_reach_finder.rb +++ b/app/lib/status_reach_finder.rb @@ -54,6 +54,7 @@ def reached_account_ids reblogs_account_ids, favourites_account_ids, replies_account_ids, + quoted_account_id, ].tap do |arr| arr.flatten! arr.compact! @@ -88,6 +89,10 @@ def replies_account_ids @status.replies.pluck(:account_id) if distributable? || unsafe? end + def quoted_account_id + @status.quote.account_id if @status.quote? + end + def followers_inboxes if @status.in_reply_to_local_account? && distributable? @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).where.not(domain: banned_domains).inboxes diff --git a/app/models/account.rb b/app/models/account.rb index 82532521a89873..7cf33926e7abdb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -330,6 +330,13 @@ def link_preview? true end + def allow_quote? + return user.setting_allow_quote if local? && user.present? + return settings['allow_quote'] if settings.present? && settings.key?('allow_quote') + + true + end + def public_statuses_count hide_statuses_count? ? 0 : statuses_count end @@ -407,6 +414,7 @@ def public_settings 'hide_followers_count' => hide_followers_count?, 'translatable_private' => translatable_private?, 'link_preview' => link_preview?, + 'allow_quote' => allow_quote?, } if Setting.enable_emoji_reaction config = config.merge({ diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb index 780c6345bb3e16..ed5a5f429f29a7 100644 --- a/app/models/concerns/has_user_settings.rb +++ b/app/models/concerns/has_user_settings.rb @@ -111,6 +111,22 @@ def setting_system_font_ui settings['web.use_system_font'] end + def setting_show_quote_in_home + settings['web.show_quote_in_home'] + end + + def setting_show_quote_in_public + settings['web.show_quote_in_public'] + end + + def setting_hide_blocking_quote + settings['web.hide_blocking_quote'] + end + + def setting_allow_quote + settings['allow_quote'] + end + def setting_noindex settings['noindex'] end @@ -127,10 +143,6 @@ def setting_link_preview settings['link_preview'] end - def setting_single_ref_to_quote - settings['single_ref_to_quote'] - end - def setting_dtl_force_with_tag settings['dtl_force_with_tag']&.to_sym || :none end diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index f443d08ca66b4c..d63a7e6f9f7960 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -14,6 +14,7 @@ # action :integer default("warn"), not null # exclude_follows :boolean default(FALSE), not null # exclude_localusers :boolean default(FALSE), not null +# with_quote :boolean default(TRUE), not null # class CustomFilter < ApplicationRecord @@ -33,7 +34,7 @@ class CustomFilter < ApplicationRecord include Expireable include Redisable - enum action: { warn: 0, hide: 1 }, _suffix: :action + enum action: { warn: 0, hide: 1, half_warn: 2 }, _suffix: :action belongs_to :account has_many :keywords, class_name: 'CustomFilterKeyword', inverse_of: :custom_filter, dependent: :destroy @@ -103,11 +104,15 @@ def self.apply_cached_filters(cached_filters, status, following = false) # ruboc if rules[:keywords].present? match = rules[:keywords].match(status.proper.searchable_text) - match = rules[:keywords].match(status.proper.references.pluck(:text).join("\n\n")) if match.nil? && status.proper.references.exists? + if match.nil? && filter.with_quote && status.proper.references.exists? + match = rules[:keywords].match(status.proper.references.pluck(:text).join("\n\n")) + match = rules[:keywords].match(status.proper.references.pluck(:spoiler_text).join("\n\n")) if match.nil? + end end keyword_matches = [match.to_s] unless match.nil? - status_matches = [status.id, status.reblog_of_id].compact & rules[:status_ids] if rules[:status_ids].present? + reference_ids = filter.with_quote ? status.proper.references.pluck(:id) : [] + status_matches = ([status.id, status.reblog_of_id] + reference_ids).compact & rules[:status_ids] if rules[:status_ids].present? next if keyword_matches.blank? && status_matches.blank? diff --git a/app/models/status.rb b/app/models/status.rb index c16c1a7c4f7f17..4c076ca5d7b745 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -30,6 +30,7 @@ # searchability :integer # markdown :boolean default(FALSE) # limited_scope :integer +# quote_of_id :bigint(8) # require 'ostruct' @@ -69,12 +70,14 @@ class Status < ApplicationRecord belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true + belongs_to :quote, foreign_key: 'quote_of_id', class_name: 'Status', inverse_of: :quotes, optional: true has_many :favourites, inverse_of: :status, dependent: :destroy has_many :emoji_reactions, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy has_many :reblogged_by_accounts, through: :reblogs, class_name: 'Account', source: :account + has_many :quotes, foreign_key: 'quote_of_id', class_name: 'Status', inverse_of: :quote has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :mentions, dependent: :destroy, inverse_of: :status has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account' @@ -193,6 +196,19 @@ class Status < ApplicationRecord account: [:account_stat, user: :role], active_mentions: { account: :account_stat }, ], + quote: [ + :application, + :tags, + :preview_cards, + :media_attachments, + :conversation, + :status_stat, + :preloadable_poll, + :reference_objects, + :scheduled_expiration_status, + account: [:account_stat, user: :role], + active_mentions: { account: :account_stat }, + ], thread: { account: :account_stat } delegate :domain, to: :account, prefix: true @@ -227,8 +243,8 @@ def reblog? !reblog_of_id.nil? end - def quote - reference_objects.where(attribute_type: 'QT').first&.target_status + def quote? + !quote_of_id.nil? end def within_realtime_window? @@ -480,12 +496,16 @@ def mutes_map(conversation_ids, account_id) ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true } end - def pins_map(status_ids, account_id) - StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } + def blocks_map(account_ids, account_id) + Block.where(account_id: account_id, target_account_id: account_ids).each_with_object({}) { |b, h| h[b.target_account_id] = true } end - def emoji_reactions_map(status_ids, account_id) - EmojiReaction.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |e, h| h[e.status_id] = true } + def domain_blocks_map(domains, account_id) + AccountDomainBlock.where(account_id: account_id, domain: domains).each_with_object({}) { |d, h| h[d.domain] = true } + end + + def pins_map(status_ids, account_id) + StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } end def emoji_reaction_allows_map(status_ids, account_id) diff --git a/app/models/status_reference.rb b/app/models/status_reference.rb index 8d5d6eba8b9961..7bbd7b323242fc 100644 --- a/app/models/status_reference.rb +++ b/app/models/status_reference.rb @@ -10,6 +10,7 @@ # created_at :datetime not null # updated_at :datetime not null # attribute_type :string +# quote :boolean default(FALSE), not null # class StatusReference < ApplicationRecord @@ -19,6 +20,8 @@ class StatusReference < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy after_commit :reset_parent_cache + after_create_commit :set_quote + after_destroy_commit :remove_quote private @@ -26,4 +29,18 @@ def reset_parent_cache Rails.cache.delete("statuses/#{status_id}") Rails.cache.delete("statuses/#{target_status_id}") end + + def set_quote + return unless quote + return if status.quote_of_id.present? + + status.quote_of_id = target_status_id + end + + def remove_quote + return unless quote + return unless status.quote_of_id == target_status_id + + status.quote_of_id = nil + end end diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index 93f7f4a64f2c7f..8518e2abf224a7 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -41,7 +41,7 @@ class KeyError < Error; end setting :dtl_force_with_tag, default: :none, in: %w(full searchability none) setting :dtl_force_subscribable, default: false setting :lock_follow_from_bot, default: false - setting :single_ref_to_quote, default: false + setting :allow_quote, default: true setting_inverse_alias :indexable, :noindex @@ -67,6 +67,9 @@ class KeyError < Error; end setting :display_media_expand, default: true setting :auto_play, default: true setting :simple_timeline_menu, default: false + setting :show_quote_in_home, default: true + setting :show_quote_in_public, default: false + setting :hide_blocking_quote, default: true end namespace :notification_emails do diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 301ec4fdc48213..b30d48e37443e0 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -40,6 +40,10 @@ def emoji_reaction? show? && !blocking_author? end + def quote? + %i(public public_unlisted unlisted).include?(record.visibility.to_sym) && show? && !blocking_author? + end + def destroy? owned? end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 9e55742403dc0b..35c9e3e3f32dba 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -3,8 +3,8 @@ class StatusRelationshipsPresenter PINNABLE_VISIBILITIES = %w(public public_unlisted unlisted login private).freeze - attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, - :bookmarks_map, :filters_map, :emoji_reactions_map, :attributes_map, :emoji_reaction_allows_map + attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map, :blocks_map, :domain_blocks_map, + :bookmarks_map, :filters_map, :attributes_map, :emoji_reaction_allows_map def initialize(statuses, current_account_id = nil, **options) @current_account_id = current_account_id @@ -14,25 +14,28 @@ def initialize(statuses, current_account_id = nil, **options) @favourites_map = {} @bookmarks_map = {} @mutes_map = {} + @blocks_map = {} + @domain_blocks_map = {} @pins_map = {} @filters_map = {} - @emoji_reactions_map = {} @emoji_reaction_allows_map = nil else - statuses = statuses.compact + statuses = statuses.compact + statuses += statuses.filter_map(&:quote) status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact conversation_ids = statuses.filter_map(&:conversation_id).uniq pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) } - @filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {}) - @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) - @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) - @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) - @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) - @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) - @emoji_reactions_map = Status.emoji_reactions_map(status_ids, current_account_id).merge(options[:emoji_reactions_map] || {}) + @filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {}) + @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) + @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) + @bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {}) + @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) + @blocks_map = Status.blocks_map(statuses.map(&:account_id), current_account_id).merge(options[:blocks_map] || {}) + @domain_blocks_map = Status.domain_blocks_map(statuses.filter_map { |status| status.account.domain }.uniq, current_account_id).merge(options[:domain_blocks_map] || {}) + @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) @emoji_reaction_allows_map = Status.emoji_reaction_allows_map(status_ids, current_account_id).merge(options[:emoji_reaction_allows_map] || {}) - @attributes_map = options[:attributes_map] || {} + @attributes_map = options[:attributes_map] || {} end end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index d8f7a328ed7a22..3c89c7b632f9b4 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -170,12 +170,10 @@ def local? object.account.local? end - def quote? - @quote ||= (object.reference_objects.count == 1 && object.account.user&.settings&.[]('single_ref_to_quote')) || object.reference_objects.where(attribute_type: 'QT').count == 1 - end + delegate :quote?, to: :object def quote_post - @quote_post ||= object.quote || object.references.first + @quote_post ||= object.quote end def quote_uri diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index c4e9927c740371..eb2990703a7208 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -62,6 +62,9 @@ def meta store[:show_trends] = Setting.trends && object.current_account.user.setting_trends store[:bookmark_category_needed] = object.current_account.user.setting_bookmark_category_needed store[:simple_timeline_menu] = object.current_account.user.setting_simple_timeline_menu + store[:show_quote_in_home] = object.current_account.user.setting_show_quote_in_home + store[:show_quote_in_public] = object.current_account.user.setting_show_quote_in_public + store[:hide_blocking_quote] = object.current_account.user.setting_hide_blocking_quote else store[:auto_play_gif] = Setting.auto_play_gif store[:display_media] = Setting.display_media @@ -69,6 +72,8 @@ def meta store[:use_blurhash] = Setting.use_blurhash store[:enable_emoji_reaction] = Setting.enable_emoji_reaction store[:show_emoji_reaction_on_timeline] = Setting.enable_emoji_reaction + store[:show_quote_in_home] = true + store[:show_quote_in_public] = true end store[:disabled_account_id] = object.disabled_account.id.to_s if object.disabled_account diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 3603026f39bd03..e9ac924f7ee897 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -16,6 +16,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :pinned, if: :pinnable? attribute :reactions, if: :reactions? attribute :expires_at, if: :will_expire? + attribute :quote_id, if: :quote? has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user? attribute :content, unless: :source_requested? @@ -33,6 +34,23 @@ class REST::StatusSerializer < ActiveModel::Serializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer + class QuotedStatusSerializer < REST::StatusSerializer + attribute :quote_muted, if: :current_user? + + def quote + nil + end + + def quote_muted + if relationships + muted || relationships.blocks_map[object.account_id] || relationships.domain_blocks_map[object.account.domain] || false + else + muted || current_user.account.blocking?(object.account_id) || current_user.account.domain_blocking?(object.account.domain) + end + end + end + belongs_to :quote, if: :quote?, serializer: QuotedStatusSerializer, relationships: -> { relationships } + def id object.id.to_s end @@ -159,6 +177,12 @@ def reactions end end + def quote_id + object.quote_of_id.to_s + end + + delegate :quote?, to: :object + def reblogged if relationships relationships.reblogs_map[object.id] || false diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index da82af8dff69ac..2ae2824a1e5e67 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -254,9 +254,8 @@ def update_emojis! def update_references! references = @json['references'].nil? ? [] : ActivityPub::FetchReferencesService.new.call(@status, @json['references']) quote = @json['quote'] || @json['quoteUrl'] || @json['quoteURL'] || @json['_misskey_quote'] - references << quote if quote - ProcessReferencesService.perform_worker_async(@status, [], references) + ProcessReferencesService.perform_worker_async(@status, [], references, [quote].compact) end def expected_type? diff --git a/app/services/process_references_service.rb b/app/services/process_references_service.rb index faf5965a7aef05..6d61c7decd3f58 100644 --- a/app/services/process_references_service.rb +++ b/app/services/process_references_service.rb @@ -10,14 +10,17 @@ class ProcessReferencesService < BaseService REFURL_EXP = /(RT|QT|BT|RN|RE)((:|;)?\s+|:|;)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/ MAX_REFERENCES = 5 - def call(status, reference_parameters, urls: nil, fetch_remote: true, no_fetch_urls: nil) + def call(status, reference_parameters, urls: nil, fetch_remote: true, no_fetch_urls: nil, quote_urls: nil) @status = status @reference_parameters = reference_parameters || [] - @urls = urls || [] + @quote_urls = quote_urls || [] + @urls = (urls - @quote_urls) || [] @no_fetch_urls = no_fetch_urls || [] @fetch_remote = fetch_remote @again = false + @attributes = {} + with_redis_lock("process_status_refs:#{@status.id}") do @references_count = old_references.size @@ -38,27 +41,27 @@ def call(status, reference_parameters, urls: nil, fetch_remote: true, no_fetch_u launch_worker if @again end - def self.need_process?(status, reference_parameters, urls) - reference_parameters.any? || (urls || []).any? || FormattingHelper.extract_status_plain_text(status).scan(REFURL_EXP).pluck(3).uniq.any? + def self.need_process?(status, reference_parameters, urls, quote_urls) + reference_parameters.any? || (urls || []).any? || (quote_urls || []).any? || FormattingHelper.extract_status_plain_text(status).scan(REFURL_EXP).pluck(3).uniq.any? end - def self.perform_worker_async(status, reference_parameters, urls) - return unless need_process?(status, reference_parameters, urls) + def self.perform_worker_async(status, reference_parameters, urls, quote_urls) + return unless need_process?(status, reference_parameters, urls, quote_urls) Rails.cache.write("status_reference:#{status.id}", true, expires_in: 10.minutes) - ProcessReferencesWorker.perform_async(status.id, reference_parameters, urls, []) + ProcessReferencesWorker.perform_async(status.id, reference_parameters, urls, [], quote_urls || []) end - def self.call_service(status, reference_parameters, urls) - return unless need_process?(status, reference_parameters, urls) + def self.call_service(status, reference_parameters, urls, quote_urls = []) + return unless need_process?(status, reference_parameters, urls, quote_urls) - ProcessReferencesService.new.call(status, reference_parameters || [], urls: urls || [], fetch_remote: false) + ProcessReferencesService.new.call(status, reference_parameters || [], urls: urls || [], fetch_remote: false, quote_urls: quote_urls) end private def references - @references ||= @reference_parameters + scan_text! + @references ||= @reference_parameters + scan_text! + quote_status_ids end def old_references @@ -88,12 +91,24 @@ def fetch_statuses!(urls) target_urls = urls + @urls target_urls.map do |url| - status = ResolveURLService.new.call(url, on_behalf_of: @status.account, fetch_remote: @fetch_remote && @no_fetch_urls.exclude?(url)) - @no_fetch_urls << url if !@fetch_remote && status.present? && status.local? + status = url_to_status(url) + @no_fetch_urls << url if !@fetch_remote && status.present? status end end + def url_to_status(url) + ResolveURLService.new.call(url, on_behalf_of: @status.account, fetch_remote: @fetch_remote && @no_fetch_urls.exclude?(url)) + end + + def quote_status_ids + @quote_status_ids ||= @quote_urls.filter_map { |url| url_to_status(url) }.map(&:id) + end + + def quotable?(target_status) + @status.account.allow_quote? && StatusPolicy.new(@status.account, target_status).quote? + end + def add_references return if added_references.empty? @@ -101,7 +116,12 @@ def add_references statuses = Status.where(id: added_references) statuses.each do |status| - @added_objects << @status.reference_objects.new(target_status: status, attribute_type: @attributes[status.id]) + attribute_type = quote_status_ids.include?(status.id) ? 'QT' : @attributes[status.id] + attribute_type = 'BT' unless quotable?(status) + quote_type = attribute_type.present? ? attribute_type.casecmp('QT').zero? : false + @status.quote_of_id = status.id if quote_type && (@status.quote_of_id.nil? || references.exclude?(@status.quote_of_id)) + @added_objects << @status.reference_objects.new(target_status: status, attribute_type: attribute_type, quote: quote_type) + status.increment_count!(:status_referred_by_count) @references_count += 1 @@ -133,6 +153,6 @@ def remove_old_references end def launch_worker - ProcessReferencesWorker.perform_async(@status.id, @reference_parameters, @urls, @no_fetch_urls) + ProcessReferencesWorker.perform_async(@status.id, @reference_parameters, @urls, @no_fetch_urls, @quote_urls) end end diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index 2f28907cb6c515..1194afc368d9f4 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -6,16 +6,16 @@ class ResolveURLService < BaseService USERNAME_STATUS_RE = %r{/@(?#{Account::USERNAME_RE})/(?[0-9]+)\Z} - def call(url, on_behalf_of: nil, fetch_remote: true) + def call(url, on_behalf_of: nil, fetch_remote: true, local_only: false) @url = url @on_behalf_of = on_behalf_of @fetch_remote = fetch_remote if local_url? process_local_url - elsif fetch_remote && !fetched_resource.nil? + elsif !local_only && fetch_remote && !fetched_resource.nil? process_url - else + elsif !local_only process_url_from_db end end diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml index ac97ccb87c6dc4..74f969cf34038c 100644 --- a/app/views/filters/_filter_fields.html.haml +++ b/app/views/filters/_filter_fields.html.haml @@ -10,12 +10,15 @@ %hr.spacer/ .fields-group - = f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true + = f.input :filter_action, as: :radio_buttons, collection: %i(half_warn warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true .fields-group = f.input :exclude_follows, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.exclude_follows') = f.input :exclude_localusers, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.exclude_localusers') +.fields-group + = f.input :with_quote, wrapper: :with_label, kmyblue: true, label: t('simple_form.labels.filters.options.with_quote') + %hr.spacer/ - unless f.object.statuses.empty? diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index fd41160c13b440..c8d82433f42968 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -46,6 +46,11 @@ .fields-group = ff.input :'web.bookmark_category_needed', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_bookmark_category_needed'), hint: I18n.t('simple_form.hints.defaults.setting_bookmark_category_needed') + .fields-group + = ff.input :'web.show_quote_in_home', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_show_quote_in_home'), hint: false + = ff.input :'web.show_quote_in_public', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_show_quote_in_public'), hint: false + = ff.input :'web.hide_blocking_quote', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_hide_blocking_quote'), hint: false + .fields-group = ff.input :'web.simple_timeline_menu', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_simple_timeline_menu') diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index c596013ef6c569..353715df01a66e 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -14,9 +14,6 @@ .fields-group = ff.input :lock_follow_from_bot, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_lock_follow_from_bot') - .fields-group - = ff.input :single_ref_to_quote, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_single_ref_to_quote'), hint: I18n.t('simple_form.hints.defaults.setting_single_ref_to_quote') - %h4= t 'preferences.posting_defaults' .fields-row diff --git a/app/views/settings/privacy_extra/show.html.haml b/app/views/settings/privacy_extra/show.html.haml index 350841b7d5d476..c688ad6373e0c5 100644 --- a/app/views/settings/privacy_extra/show.html.haml +++ b/app/views/settings/privacy_extra/show.html.haml @@ -21,6 +21,9 @@ .fields-group = ff.input :link_preview, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_link_preview'), hint: I18n.t('simple_form.hints.defaults.setting_link_preview') + .fields-group + = ff.input :allow_quote, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_allow_quote'), hint: false + %h4= t 'privacy_extra.stop_deliver' %p.lead= t('privacy_extra.stop_deliver_hint_html') diff --git a/app/workers/process_references_worker.rb b/app/workers/process_references_worker.rb index f082744857ad61..26dfbae465aeb6 100644 --- a/app/workers/process_references_worker.rb +++ b/app/workers/process_references_worker.rb @@ -5,8 +5,8 @@ class ProcessReferencesWorker sidekiq_options queue: 'pull', retry: 3 - def perform(status_id, ids, urls, no_fetch_urls = nil) - ProcessReferencesService.new.call(Status.find(status_id), ids || [], urls: urls || [], no_fetch_urls: no_fetch_urls) + def perform(status_id, ids, urls, no_fetch_urls = nil, quote_urls = nil) + ProcessReferencesService.new.call(Status.find(status_id), ids || [], urls: urls || [], no_fetch_urls: no_fetch_urls, quote_urls: quote_urls || []) rescue ActiveRecord::RecordNotFound true end diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 2012e1a7f78b55..c37a6de56abf54 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -87,6 +87,7 @@ en: filters: action: Chose which action to perform when a post matches the filter actions: + half_warn: Hide the filtered content (exclude account info) behind a warning mentioning the filter's title hide: Completely hide the filtered content, behaving as if it did not exist warn: Hide the filtered content behind a warning mentioning the filter's title form_admin_settings: @@ -226,6 +227,7 @@ en: phrase: Keyword or phrase setting_advanced_layout: Enable advanced web interface setting_aggregate_reblogs: Group boosts in timelines + setting_allow_quote: Allow quote your posts setting_always_send_emails: Always send e-mail notifications setting_auto_play_gif: Auto-play animated GIFs setting_bio_markdown: Enable profile markdown @@ -259,6 +261,7 @@ en: mutuals_only: Mutuals only outside_only: Followings or followers only setting_expand_spoilers: Always expand posts marked with content warnings + setting_hide_blocking_quote: Hide posts which have a quote written by the user you are blocking setting_hide_followers_count: Hide followers count setting_hide_following_count: Hide following count setting_hide_network: Hide your social graph @@ -274,6 +277,8 @@ en: setting_send_without_domain_blocks: Send your post to all server with administrator set as rejecting-post-server for protect you [DEPRECATED] setting_show_application: Disclose application used to send posts setting_show_emoji_reaction_on_timeline: Show all stamps on timeline + setting_show_quote_in_home: Show quotes in home, list or antenna timelines + setting_show_quote_in_public: Show quotes in public timelines setting_simple_timeline_menu: Reduce post menu on timeline setting_single_ref_to_quote: Deliver single reference to other server as quote setting_stay_privacy: Not change privacy after post @@ -308,11 +313,13 @@ en: name: Hashtag filters: actions: + half_warn: Half hide with a warning hide: Hide completely warn: Hide with a warning options: exclude_follows: Exclude following users exclude_localusers: Exclude local users + with_quote: Also check quote or references form_admin_settings: activity_api_enabled: Publish aggregate statistics about user activity in the API backups_retention_period: User archive retention period diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 2a03f00fdbc3f4..3904f757518bf6 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -100,6 +100,7 @@ ja: filters: action: 投稿がフィルタに一致したときに実行するアクションを選択 actions: + half_warn: フィルターに一致した投稿の本文のみを非表示にし、フィルターのタイトルを含む警告を表示します hide: フィルタに一致した投稿を完全に非表示にします warn: フィルタに一致した投稿を非表示にし、フィルタのタイトルを含む警告を表示します form_admin_settings: @@ -239,6 +240,7 @@ ja: phrase: キーワードまたはフレーズ setting_advanced_layout: 上級者向けUIを有効にする setting_aggregate_reblogs: ブーストをまとめる + setting_allow_quote: 引用を許可する setting_always_send_emails: 常にメール通知を送信する setting_auto_play_gif: アニメーションGIFを自動再生する setting_bio_markdown: プロフィールのMarkdownを有効にする @@ -273,6 +275,7 @@ ja: setting_emoji_reaction_streaming_notify_impl2: Nyastodon, Catstodon, glitch-soc互換のスタンプ機能を有効にする setting_enable_emoji_reaction: スタンプ機能を使用する setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する + setting_hide_blocking_quote: ブロックしたユーザーの投稿を引用した投稿を隠す setting_hide_followers_count: フォロワー数を隠す setting_hide_following_count: フォロー数を隠す setting_hide_network: 繋がりを隠す @@ -280,6 +283,8 @@ ja: setting_hide_statuses_count: 投稿数を隠す setting_link_preview: リンクのプレビューを生成する setting_lock_follow_from_bot: botからのフォローを承認制にする + setting_show_quote_in_home: ホーム・リスト・アンテナなどで引用を表示する + setting_show_quote_in_public: 公開タイムライン(ローカル・連合)で引用を表示する setting_stay_privacy: 投稿時に公開範囲を保存する setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する @@ -323,11 +328,13 @@ ja: name: ハッシュタグ filters: actions: + half_warn: アカウント名だけを出し、本文は警告で隠す hide: 完全に隠す warn: 警告付きで隠す options: exclude_follows: フォロー中のユーザーをフィルターの対象にしない exclude_localusers: ローカルユーザーをフィルターの対象にしない + with_quote: 引用・参照の内容をフィルターの対象に含める form_admin_settings: activity_api_enabled: APIでユーザーアクティビティに関する集計統計を公開する backups_retention_period: ユーザーアーカイブの保持期間 diff --git a/db/migrate/20230930233930_add_quote_to_status_references.rb b/db/migrate/20230930233930_add_quote_to_status_references.rb new file mode 100644 index 00000000000000..f2bd6cd48d8402 --- /dev/null +++ b/db/migrate/20230930233930_add_quote_to_status_references.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddQuoteToStatusReferences < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + class StatusReference < ApplicationRecord; end + + def up + safety_assured do + add_column_with_default :status_references, :quote, :boolean, default: false, allow_null: false + StatusReference.where(attribute_type: 'QT').update_all(quote: true) # rubocop:disable Rails/SkipsModelValidations + end + end + + def down + safety_assured do + remove_column :status_references, :quote + end + end +end diff --git a/db/migrate/20231001031337_add_quote_to_statuses.rb b/db/migrate/20231001031337_add_quote_to_statuses.rb new file mode 100644 index 00000000000000..c60ec58ecf921d --- /dev/null +++ b/db/migrate/20231001031337_add_quote_to_statuses.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddQuoteToStatuses < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + class StatusReference < ApplicationRecord + belongs_to :status + belongs_to :target_status, class_name: 'Status' + end + + def up + safety_assured do + add_column_with_default :statuses, :quote_of_id, :bigint, default: nil, allow_null: true + + StatusReference.transaction do + StatusReference.where(quote: true).includes(:status).each do |ref| + ref.status.update(quote_of_id: ref.target_status_id) + end + end + end + end + + def down + safety_assured do + remove_column :statuses, :quote_of_id + end + end +end diff --git a/db/migrate/20231001050733_add_with_quote_to_custom_filters.rb b/db/migrate/20231001050733_add_with_quote_to_custom_filters.rb new file mode 100644 index 00000000000000..074f55248282ad --- /dev/null +++ b/db/migrate/20231001050733_add_with_quote_to_custom_filters.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddWithQuoteToCustomFilters < ActiveRecord::Migration[7.0] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def change + safety_assured do + add_column_with_default :custom_filters, :with_quote, :boolean, default: true, allow_null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e17320cdaa0637..8ee0450744a4f8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_09_23_103430) do +ActiveRecord::Schema[7.0].define(version: 2023_10_01_050733) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -536,6 +536,7 @@ t.integer "action", default: 0, null: false t.boolean "exclude_follows", default: false, null: false t.boolean "exclude_localusers", default: false, null: false + t.boolean "with_quote", default: true, null: false t.index ["account_id"], name: "index_custom_filters_on_account_id" end @@ -1143,6 +1144,7 @@ t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "attribute_type" + t.boolean "quote", default: false, null: false t.index ["status_id"], name: "index_status_references_on_status_id" t.index ["target_status_id"], name: "index_status_references_on_target_status_id" end @@ -1199,6 +1201,7 @@ t.integer "searchability" t.boolean "markdown", default: false t.integer "limited_scope" + t.bigint "quote_of_id" 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)" diff --git a/spec/fabricators/status_reference_fabricator.rb b/spec/fabricators/status_reference_fabricator.rb new file mode 100644 index 00000000000000..0eff89c14ba727 --- /dev/null +++ b/spec/fabricators/status_reference_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:status_reference) do + status { Fabricate.build(:status) } + target_status { Fabricate.build(:status) } + attribute_type 'BT' + quote false +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index c199fcd0382113..24a404d7264ae4 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -1089,6 +1089,97 @@ end end + context 'with references' do + let(:recipient) { Fabricate(:account) } + let!(:target_status) { Fabricate(:status, account: Fabricate(:account, domain: nil)) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + references: { + id: 'target_status', + type: 'Collection', + first: { + type: 'CollectionPage', + next: nil, + partOf: 'target_status', + items: [ + ActivityPub::TagManager.instance.uri_for(target_status), + ], + }, + }, + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.quote).to be_nil + expect(status.references.pluck(:id)).to eq [target_status.id] + end + end + + context 'with quote' do + let(:recipient) { Fabricate(:account) } + let!(:target_status) { Fabricate(:status, account: Fabricate(:account, domain: nil)) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(target_status), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.references.pluck(:id)).to eq [target_status.id] + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + end + end + + context 'with references and quote' do + let(:recipient) { Fabricate(:account) } + let!(:target_status) { Fabricate(:status, account: Fabricate(:account, domain: nil)) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(target_status), + references: { + id: 'target_status', + type: 'Collection', + first: { + type: 'CollectionPage', + next: nil, + partOf: 'target_status', + items: [ + ActivityPub::TagManager.instance.uri_for(target_status), + ], + }, + }, + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.references.pluck(:id)).to eq [target_status.id] + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + end + end + context 'with language' do let(:to) { 'https://www.w3.org/ns/activitystreams#Public' } let(:object_json) do @@ -1274,6 +1365,53 @@ end end + context 'when sender quotes to local status' do + subject { described_class.new(json, sender, delivery: true) } + + let!(:local_status) { Fabricate(:status) } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(local_status), + } + end + + before do + subject.perform + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'when sender quotes to non-local status' do + subject { described_class.new(json, sender, delivery: true) } + + let!(:remote_status) { Fabricate(:status, uri: 'https://foo.bar/among', account: Fabricate(:account, domain: 'foo.bar', uri: 'https://foo.bar/account')) } + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + quote: ActivityPub::TagManager.instance.uri_for(remote_status), + } + end + + before do + subject.perform + end + + it 'creates status' do + expect(sender.statuses.count).to eq 0 + end + end + context 'when sender targets a local user' do subject { described_class.new(json, sender, delivery: true) } diff --git a/spec/lib/status_reach_finder_spec.rb b/spec/lib/status_reach_finder_spec.rb index 4292f12bc67139..ba319b5423418d 100644 --- a/spec/lib/status_reach_finder_spec.rb +++ b/spec/lib/status_reach_finder_spec.rb @@ -8,10 +8,11 @@ subject { described_class.new(status) } let(:parent_status) { nil } + let(:quoted_status) { nil } let(:visibility) { :public } let(:searchability) { :public } let(:alice) { Fabricate(:account, username: 'alice') } - let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility, searchability: searchability) } + let(:status) { Fabricate(:status, account: alice, thread: parent_status, quote_of_id: quoted_status&.id, visibility: visibility, searchability: searchability) } context 'with a simple case' do let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } @@ -165,6 +166,15 @@ end end end + + context 'when it is a quote to a remote account' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } + let(:quoted_status) { Fabricate(:status, account: bob) } + + it 'includes the inbox of the quoted-to account' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' + end + end end context 'with extended domain block' do diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 64623075943b37..36f4fddec9528c 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -217,6 +217,39 @@ end end + describe '#quote' do + let(:target_status) { Fabricate(:status) } + let(:quote) { true } + + before do + Fabricate(:status_reference, status: subject, target_status: target_status, quote: quote) + end + + context 'when quoting single' do + it 'get quote' do + expect(subject.quote).to_not be_nil + expect(subject.quote.id).to eq target_status.id + end + end + + context 'when multiple quotes' do + it 'get quote' do + target2 = Fabricate(:status) + Fabricate(:status_reference, status: subject, quote: quote) + expect(subject.quote).to_not be_nil + expect([target_status.id, target2.id].include?(subject.quote.id)).to be true + end + end + + context 'when no quote but reference' do + let(:quote) { false } + + it 'get quote' do + expect(subject.quote).to be_nil + end + end + end + describe '#content' do it 'returns the text of the status if it is not a reblog' do expect(subject.content).to eql subject.text @@ -324,6 +357,38 @@ end end + describe '.blocks_map' do + subject { described_class.blocks_map([status.account.id], account) } + + let(:status) { Fabricate(:status) } + let(:account) { Fabricate(:account) } + + it 'returns a hash' do + expect(subject).to be_a Hash + end + + it 'contains true value' do + account.block!(status.account) + expect(subject[status.account.id]).to be true + end + end + + describe '.domain_blocks_map' do + subject { described_class.domain_blocks_map([status.account.domain], account) } + + let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'foo.bar', uri: 'https://foo.bar/status')) } + let(:account) { Fabricate(:account) } + + it 'returns a hash' do + expect(subject).to be_a Hash + end + + it 'contains true value' do + account.block_domain!(status.account.domain) + expect(subject[status.account.domain]).to be true + end + end + describe '.favourites_map' do subject { described_class.favourites_map([status], account) } diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 271c70804bb96b..3bdc2084d8a7be 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -167,6 +167,48 @@ end end + context 'with the permission of emoji_reaction?' do + permissions :emoji_reaction? do + it 'grants access when viewer is not blocked' do + follow = Fabricate(:follow) + status.account = follow.target_account + + expect(subject).to permit(follow.account, status) + end + + it 'denies when viewer is blocked' do + block = Fabricate(:block) + status.account = block.target_account + + expect(subject).to_not permit(block.account, status) + end + end + end + + context 'with the permission of quote?' do + permissions :quote? do + it 'grants access when viewer is not blocked' do + follow = Fabricate(:follow) + status.account = follow.target_account + + expect(subject).to permit(follow.account, status) + end + + it 'denies when viewer is blocked' do + block = Fabricate(:block) + status.account = block.target_account + + expect(subject).to_not permit(block.account, status) + end + + it 'denies when private visibility' do + status.visibility = :private + + expect(subject).to_not permit(Fabricate(:account), status) + end + end + end + context 'with the permission of update?' do permissions :update? do it 'grants access if owner' do diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index 0425e2e66ba33f..910b5010ecf39e 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -15,12 +15,10 @@ let!(:reply_by_account_visibility_direct) { Fabricate(:status, account: account, thread: parent, visibility: :direct) } let!(:referred) { nil } let!(:referred2) { nil } - let(:convert_to_quote) { false } before(:each) do parent.references << referred if referred.present? parent.references << referred2 if referred2.present? - account.user&.settings&.[]=('single_ref_to_quote', true) if convert_to_quote @serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter) end @@ -64,28 +62,4 @@ expect(subject['references']['first']['items']).to include referred.uri end end - - context 'when has quote and convert setting' do - let(:referred) { Fabricate(:status) } - let(:convert_to_quote) { true } - - it 'has as quote' do - expect(subject['quoteUri']).to_not be_nil - expect(subject['quoteUri']).to eq referred.uri - expect(subject['_misskey_quote']).to eq referred.uri - expect(subject['references']['first']['items']).to include referred.uri - end - end - - context 'when has multiple references and convert setting' do - let(:referred) { Fabricate(:status) } - let(:referred2) { Fabricate(:status) } - let(:convert_to_quote) { true } - - it 'has as quote' do - expect(subject['quoteUri']).to be_nil - expect(subject['references']['first']['items']).to include referred.uri - expect(subject['references']['first']['items']).to include referred2.uri - end - end end diff --git a/spec/services/process_references_service_spec.rb b/spec/services/process_references_service_spec.rb index c41144a2aaacfa..60857278ee39e9 100644 --- a/spec/services/process_references_service_spec.rb +++ b/spec/services/process_references_service_spec.rb @@ -10,6 +10,7 @@ let(:status) { Fabricate(:status, account: account, text: text, visibility: visibility) } let(:target_status) { Fabricate(:status, account: Fabricate(:user).account, visibility: target_status_visibility) } let(:target_status_uri) { ActivityPub::TagManager.instance.uri_for(target_status) } + let(:quote_urls) { nil } def notify?(target_status_id = nil) target_status_id ||= target_status.id @@ -18,7 +19,7 @@ def notify?(target_status_id = nil) describe 'posting new status' do subject do - described_class.new.call(status, reference_parameters, urls: urls, fetch_remote: fetch_remote) + described_class.new.call(status, reference_parameters, urls: urls, fetch_remote: fetch_remote, quote_urls: quote_urls) status.reference_objects.pluck(:target_status_id, :attribute_type) end @@ -35,6 +36,10 @@ def notify?(target_status_id = nil) expect(subject.pluck(1)).to include 'RT' expect(notify?).to be true end + + it 'not quote' do + expect(status.quote).to be_nil + end end context 'when multiple references' do @@ -86,6 +91,48 @@ def notify?(target_status_id = nil) end end + context 'with quote as parameter only' do + let(:text) { 'Hello' } + let(:quote_urls) { [ActivityPub::TagManager.instance.uri_for(target_status)] } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject.pluck(0)).to include target_status.id + expect(subject.pluck(1)).to include 'QT' + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?).to be true + end + end + + context 'with quote as parameter and embed' do + let(:text) { "Hello QT #{target_status_uri}" } + let(:quote_urls) { [ActivityPub::TagManager.instance.uri_for(target_status)] } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject.pluck(0)).to include target_status.id + expect(subject.pluck(1)).to include 'QT' + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?).to be true + end + end + + context 'with quote as parameter but embed is not quote' do + let(:text) { "Hello RE #{target_status_uri}" } + let(:quote_urls) { [ActivityPub::TagManager.instance.uri_for(target_status)] } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject.pluck(0)).to include target_status.id + expect(subject.pluck(1)).to include 'QT' + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?).to be true + end + end + context 'with quote and reference' do let(:target_status2) { Fabricate(:status) } let(:target_status2_uri) { ActivityPub::TagManager.instance.uri_for(target_status2) } @@ -240,6 +287,17 @@ def notify?(target_status_id = nil) end end + context 'when remove quote' do + let(:text) { "QT #{target_status_uri}" } + let(:new_text) { 'Hello' } + + it 'post status' do + expect(subject.size).to eq 0 + expect(status.quote).to be_nil + expect(notify?).to be false + end + end + context 'when change reference' do let(:text) { "BT #{target_status_uri}" } let(:new_text) { "BT #{target_status2_uri}" } @@ -250,5 +308,43 @@ def notify?(target_status_id = nil) expect(notify?(target_status2.id)).to be true end end + + context 'when change quote' do + let(:text) { "QT #{target_status_uri}" } + let(:new_text) { "QT #{target_status2_uri}" } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject).to include target_status2.id + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status2.id + expect(notify?(target_status2.id)).to be true + end + end + + context 'when change quote to reference', pending: 'Will fix later' do + let(:text) { "QT #{target_status_uri}" } + let(:new_text) { "RT #{target_status_uri}" } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject).to include target_status.id + expect(status.quote).to be_nil + expect(notify?(target_status.id)).to be true + end + end + + context 'when change reference to quote', pending: 'Will fix later' do + let(:text) { "RT #{target_status_uri}" } + let(:new_text) { "QT #{target_status_uri}" } + + it 'post status' do + expect(subject.size).to eq 1 + expect(subject).to include target_status.id + expect(status.quote).to_not be_nil + expect(status.quote.id).to eq target_status.id + expect(notify?(target_status.id)).to be true + end + end end end diff --git a/streaming/index.js b/streaming/index.js index 1851de3cd06da8..b31da1fa7a4a4c 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -801,8 +801,10 @@ const startServer = async () => { // reference_texts property is not working if ProcessReferencesWorker is // used on PostStatusService and so on. (Asynchronous processing) - const reference_texts = payload.reference_texts || []; - delete payload.reference_texts; + const reference_texts = payload?.reference_texts || []; + if (payload && typeof payload.reference_texts !== 'undefined') { + delete payload.reference_texts; + } // Streaming only needs to apply filtering to some channels and only to // some events. This is because majority of the filtering happens on the