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