diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 92ccb8061..29d62fba1 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -7,8 +7,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
def index
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer,
+ relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private
diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb
index aa3fb88f0..46621ed5d 100644
--- a/app/controllers/api/v1/bookmarks_controller.rb
+++ b/app/controllers/api/v1/bookmarks_controller.rb
@@ -6,8 +6,11 @@ class Api::V1::BookmarksController < Api::BaseController
after_action :insert_pagination_headers
def index
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer,
+ relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index 21836bc17..45c7c3c3a 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -6,8 +6,11 @@ class Api::V1::FavouritesController < Api::BaseController
after_action :insert_pagination_headers
def index
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer,
+ relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 106fc8224..7f9e79cf1 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -28,10 +28,13 @@ class Api::V1::StatusesController < Api::BaseController
loaded_ancestors = cache_collection(ancestors_results, Status)
loaded_descendants = cache_collection(descendants_results, Status)
- @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
- statuses = [@status] + @context.ancestors + @context.descendants
+ @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
+ statuses = [@status] + @context.ancestors + @context.descendants
+ account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
- render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
+ render json: @context, serializer: REST::ContextSerializer,
+ relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
def create
@@ -46,7 +49,8 @@ class Api::V1::StatusesController < Api::BaseController
application: doorkeeper_token.application,
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'],
- with_rate_limit: true)
+ with_rate_limit: true,
+ quote_id: status_params[:quote_id].presence)
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end
@@ -85,6 +89,7 @@ class Api::V1::StatusesController < Api::BaseController
:spoiler_text,
:visibility,
:scheduled_at,
+ :quote_id,
media_ids: [],
poll: [
:multiple,
diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb
index ae6dbcb8b..937ecf6f7 100644
--- a/app/controllers/api/v1/timelines/home_controller.rb
+++ b/app/controllers/api/v1/timelines/home_controller.rb
@@ -6,11 +6,13 @@ class Api::V1::Timelines::HomeController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
- @statuses = load_statuses
+ @statuses = load_statuses
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id),
status: account_home_feed.regenerating? ? 206 : 200
end
diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb
index a15eae468..731eb6aad 100644
--- a/app/controllers/api/v1/timelines/list_controller.rb
+++ b/app/controllers/api/v1/timelines/list_controller.rb
@@ -9,9 +9,12 @@ class Api::V1::Timelines::ListController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+
render json: @statuses,
each_serializer: REST::StatusSerializer,
- relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
+ relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index d253b744f..61985ffc7 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -5,8 +5,12 @@ class Api::V1::Timelines::PublicController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+
+ render json: @statuses, each_serializer: REST::StatusSerializer,
+ relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 64a1db58d..b813ca220 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -5,8 +5,12 @@ class Api::V1::Timelines::TagController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
- @statuses = load_statuses
- render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+ @statuses = load_statuses
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+
+ render json: @statuses, each_serializer: REST::StatusSerializer,
+ relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
+ account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private
diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js
index 544ed2ff2..b3e0a2ab4 100644
--- a/app/javascript/mastodon/actions/bookmarks.js
+++ b/app/javascript/mastodon/actions/bookmarks.js
@@ -1,5 +1,7 @@
+import { fetchRelationships } from './accounts';
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
+import { uniq } from '../utils/uniq';
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
@@ -20,6 +22,7 @@ export function fetchBookmarkedStatuses() {
api(getState).get('/api/v1/bookmarks').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchBookmarkedStatusesFail(error));
@@ -61,6 +64,7 @@ export function expandBookmarkedStatuses() {
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandBookmarkedStatusesFail(error));
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 891403969..b011fdcc6 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -19,6 +19,8 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
+export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
@@ -100,6 +102,23 @@ export function cancelReplyCompose() {
};
};
+export function quoteCompose(status, routerHistory) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: COMPOSE_QUOTE,
+ status: status,
+ });
+
+ ensureComposeIsVisible(getState, routerHistory);
+ };
+};
+
+export function cancelQuoteCompose() {
+ return {
+ type: COMPOSE_QUOTE_CANCEL,
+ };
+};
+
export function resetCompose() {
return {
type: COMPOSE_RESET,
@@ -147,6 +166,7 @@ export function submitCompose(routerHistory) {
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
+ quote_id: getState().getIn(['compose', 'quote_from'], null),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js
index 9448b1efe..9b28ac4c4 100644
--- a/app/javascript/mastodon/actions/favourites.js
+++ b/app/javascript/mastodon/actions/favourites.js
@@ -1,5 +1,7 @@
+import { fetchRelationships } from './accounts';
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
+import { uniq } from '../utils/uniq';
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
@@ -20,6 +22,7 @@ export function fetchFavouritedStatuses() {
api(getState).get('/api/v1/favourites').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchFavouritedStatusesFail(error));
@@ -64,6 +67,7 @@ export function expandFavouritedStatuses() {
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFavouritedStatusesFail(error));
diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
index f4372fb31..35032fc70 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -70,6 +70,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));
}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index dca44917a..32979fbb2 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -60,6 +60,8 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.hidden = normalOldStatus.get('hidden');
+ normalStatus.quote = normalOldStatus.get('quote');
+ normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
} else {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n');
@@ -69,6 +71,11 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
+
+ if (status.quote) {
+ normalStatus.quote = normalizeStatus(status.quote, null);
+ normalStatus.quote_hidden = normalStatus.quote.hidden;
+ }
}
return normalStatus;
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 3464ac995..22f1e0cbb 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -46,7 +46,7 @@ defineMessages({
});
const fetchRelatedRelationships = (dispatch, notifications) => {
- const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+ const accountIds = notifications.map(item => item.account.id);
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 3fc7c0702..d457bcffc 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -28,6 +28,9 @@ export const STATUS_REVEAL = 'STATUS_REVEAL';
export const STATUS_HIDE = 'STATUS_HIDE';
export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
+export const QUOTE_REVEAL = 'QUOTE_REVEAL';
+export const QUOTE_HIDE = 'QUOTE_HIDE';
+
export const REDRAFT = 'REDRAFT';
export function fetchStatusRequest(id, skipLoading) {
@@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
isCollapsed,
};
}
+
+export function hideQuote(ids) {
+ if (!Array.isArray(ids)) {
+ ids = [ids];
+ }
+
+ return {
+ type: QUOTE_HIDE,
+ ids,
+ };
+};
+
+export function revealQuote(ids) {
+ if (!Array.isArray(ids)) {
+ ids = [ids];
+ }
+
+ return {
+ type: QUOTE_REVEAL,
+ ids,
+ };
+};
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index de1725acf..029924fb2 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,9 +1,11 @@
+import { fetchRelationships } from './accounts';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import api, { getLinks } from 'mastodon/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
+import { uniq } from '../utils/uniq';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@@ -30,6 +32,7 @@ export function updateTimeline(timeline, status, accept) {
}
dispatch(importFetchedStatus(status));
+ dispatch(fetchRelationships([status.reblog ? status.reblog.account.id : status.account.id, status.quote ? status.quote.account.id : null].filter(x => x)));
dispatch({
type: TIMELINE_UPDATE,
@@ -102,6 +105,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchRelationships(uniq(response.data.map(item => [item.reblog ? item.reblog.account.id : item.account.id, item.quote ? item.quote.account.id : null]).flat().filter(x => x))));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
if (timelineId === 'home') {
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 0a8f42585..7d372bde4 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
+ quote: PropTypes.bool,
};
static defaultProps = {
standalone: false,
+ quote: false,
};
state = {
@@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent {
}
render () {
- const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
+ const { media, intl, sensitive, height, defaultWidth, standalone, autoplay, quote } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@@ -329,6 +331,10 @@ class MediaGallery extends React.PureComponent {
style.height = height;
}
+ if (quote && style.height) {
+ style.height /= 2;
+ }
+
const size = media.take(4).size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 295e83f58..c5d55b437 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -1,4 +1,5 @@
import React from 'react';
+import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
@@ -23,6 +24,29 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
+export const mapStateToProps = (state, props) => {
+ let status = props.status;
+
+ if (status === null) {
+ return null;
+ }
+
+ if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+ status = status.get('reblog');
+ }
+
+ if (status.get('quote', null) === null) {
+ return {
+ quoteMuted: !!status.get('quote_id', null),
+ };
+ }
+
+ const id = status.getIn(['quote', 'account', 'id'], null);
+ return {
+ quoteMuted: id !== null && (state.getIn(['relationships', id, 'muting']) || state.getIn(['relationships', id, 'blocking']) || state.getIn(['relationships', id, 'blocked_by']) || state.getIn(['relationships', id, 'domain_blocking']) || status.getIn(['quote', 'muted'])),
+ };
+};
+
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
@@ -59,7 +83,61 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
-export default @injectIntl
+export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpandedQuoteToggle, identity, media, router, contextType = 'home') => {
+ const quoteStatus = status.get('quote', null);
+
+ if (!quoteStatus) {
+ return null;
+ }
+
+ const quoteInner = (() => {
+ const hideUnlisted = quoteStatus.get('visibility') === 'unlisted'
+ && ['public', 'community', 'hashtag'].includes(contextType);
+ if (quoteMuted || hideUnlisted) {
+ const content = (() => {
+ if (quoteMuted) {
+ return (
+
(.+)<\/p>$/, '\1')
+ html = Sanitize.clean(html).delete("\n").truncate(150)
+ html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
+ html.html_safe
+ end
+
def reformat(html)
sanitize(html, Sanitize::Config::MASTODON_STRICT)
rescue ArgumentError
@@ -192,6 +204,12 @@ class Formatter
end
# rubocop:enable Metrics/BlockNesting
+ def quotify(html, status)
+ url = ActivityPub::TagManager.instance.url_for(status.quote)
+ link = encode_and_link_urls(url)
+ html.sub(/(<[^>]+>)\z/, "
~~~~~~~~~~
[#{link}]\\1")
+ end
+
def rewrite(text, entities)
text = text.to_s
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index 8f700197b..04929d311 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -31,6 +31,7 @@ class Sanitize
next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
next true if e =~ /^(mention|hashtag)$/ # semantic classes
next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
+ next true if e =~ /^quote-inline$/ # quote inline classes
end
node['class'] = class_list.join(' ')
diff --git a/app/models/status.rb b/app/models/status.rb
index b426f9d5b..32de28233 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -21,6 +21,7 @@
# account_id :bigint(8) not null
# application_id :bigint(8)
# in_reply_to_account_id :bigint(8)
+# quote_id :bigint(8)
# poll_id :bigint(8)
# deleted_at :datetime
#
@@ -55,6 +56,7 @@ 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_id', class_name: 'Status', inverse_of: :quoted, optional: true
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy
@@ -63,6 +65,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
+ has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@@ -77,6 +80,7 @@ class Status < ApplicationRecord
validates_with DisallowedHashtagsValidator
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
+ validates :quote_visibility, inclusion: { in: %w(public unlisted) }, if: :quote?
accepts_nested_attributes_for :poll
@@ -165,6 +169,14 @@ class Status < ApplicationRecord
!reblog_of_id.nil?
end
+ def quote?
+ !quote_id.nil? && quote
+ end
+
+ def quote_visibility
+ quote&.visibility
+ end
+
def within_realtime_window?
created_at >= REAL_TIME_WINDOW.ago
end
@@ -225,7 +237,12 @@ class Status < ApplicationRecord
fields = [spoiler_text, text]
fields += preloadable_poll.options unless preloadable_poll.nil?
- @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
+ quote_fields = []
+ quote_fields += [quote.spoiler_text, quote.text] if quote?
+ quote_fields += quote.preloadable_poll.options unless quote&.preloadable_poll.nil?
+
+ @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) +
+ CustomEmoji.from_text(quote_fields.join(' '), quote&.account&.domain)
end
def replies_count
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 6f9e1ca63..43e6e626c 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -9,6 +9,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:atom_uri, :in_reply_to_atom_uri,
:conversation
+ attribute :quote_url, if: -> { object.quote? }
+ attribute :misskey_quote, key: :_misskey_quote, if: -> { object.quote? }
+ attribute :misskey_content, key: :_misskey_content, if: -> { object.quote? }
attribute :content
attribute :content_map, if: :language?
@@ -125,6 +128,16 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
+ def quote_url
+ ActivityPub::TagManager.instance.uri_for(object.quote) if object.quote?
+ end
+
+ alias misskey_quote quote_url
+
+ def misskey_content
+ object.text if object.quote?
+ end
+
def local?
object.account.local?
end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index b388e448e..5021d35ee 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -5,7 +5,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :uri, :title, :short_description, :description, :email,
:version, :urls, :stats, :thumbnail,
- :languages, :registrations, :approval_required, :invites_enabled
+ :languages, :registrations, :approval_required, :invites_enabled,
+ :feature_quote
has_one :contact_account, serializer: REST::AccountSerializer
@@ -67,6 +68,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
Setting.min_invite_role == 'user'
end
+ def feature_quote
+ true
+ end
+
private
def instance_presenter
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index bb6df90b7..41607f4fe 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -15,6 +15,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :content, unless: :source_requested?
attribute :text, if: :source_requested?
+ attribute :quote_id, if: -> { object.quote? }
+
belongs_to :reblog, serializer: REST::StatusSerializer
belongs_to :application, if: :show_application?
belongs_to :account, serializer: REST::AccountSerializer
@@ -39,6 +41,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.in_reply_to_account_id&.to_s
end
+ def quote_id
+ object.quote_id.to_s
+ end
+
def current_user?
!current_user.nil?
end
@@ -167,3 +173,26 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
end
+
+class REST::NestedQuoteSerializer < REST::StatusSerializer
+ attribute :quote do
+ nil
+ end
+ attribute :quote_muted, if: :current_user?
+
+ def quote_muted
+ if instance_options && instance_options[:account_relationships]
+ !!instance_options[:account_relationships].muting[object.account_id] ||
+ instance_options[:account_relationships].blocking[object.account_id] ||
+ instance_options[:account_relationships].blocked_by[object.account_id] ||
+ instance_options[:account_relationships].domain_blocking[object.account_id]
+ else
+ current_user.account.muting?(object.account) || object.account.blocking?(current_user.account) || current_user.account.blocking?(object.account) || current_user.account.domain_blocking?(object.account.domain)
+ end
+ end
+
+end
+
+class REST::StatusSerializer < ActiveModel::Serializer
+ belongs_to :quote, serializer: REST::NestedQuoteSerializer
+end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 7efa31054..19b57f43f 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -66,7 +66,7 @@ class FetchLinkCardService < BaseService
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).normalize }
else
html = Nokogiri::HTML(@status.text)
- links = html.css('a')
+ links = html.css(':not(.quote-inline) > a')
urls = links.map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.compact.map(&:normalize).compact
end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 0a383d6a3..cad7a2343 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -20,17 +20,20 @@ class PostStatusService < BaseService
# @option [Doorkeeper::Application] :application
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
+ # @option [Integer] :quote_id
# @return [Status]
def call(account, options = {})
@account = account
@options = options
@text = @options[:text] || ''
@in_reply_to = @options[:thread]
+ @quote_id = @options[:quote_id]
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
validate_media!
preprocess_attributes!
+ preprocess_quote!
if scheduled?
schedule_status!
@@ -47,6 +50,19 @@ class PostStatusService < BaseService
private
+ def status_from_uri(uri)
+ ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
+ end
+
+ def quote_from_url(url)
+ return nil if url.nil?
+
+ quote = ResolveURLService.new.call(url)
+ status_from_uri(quote.uri) if quote
+ rescue
+ nil
+ end
+
def preprocess_attributes!
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@@ -54,10 +70,21 @@ class PostStatusService < BaseService
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
+
+ if @quote_id.nil? && (link = @text&.match(/\n\[?(#{FetchLinkCardService::URL_PATTERN})\]?$/))
+ @text = @text&.lines(chomp: true)[0...-1].join("\n") if (@quote_id = quote_from_url(link[1])&.id)
+ end
rescue ArgumentError
raise ActiveRecord::RecordInvalid
end
+ def preprocess_quote!
+ if @quote_id.present?
+ quote = Status.find(@quote_id)
+ @quote_id = quote.reblog_of_id.to_s if quote.reblog?
+ end
+ end
+
def process_status!
# The following transaction block is needed to wrap the UPDATEs to
# the media attachments when the status is created
@@ -164,6 +191,7 @@ class PostStatusService < BaseService
language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
application: @options[:application],
rate_limit: @options[:with_rate_limit],
+ quote_id: @quote_id,
}.compact
end
diff --git a/app/views/statuses/_author.html.haml b/app/views/statuses/_author.html.haml
new file mode 100644
index 000000000..f0b614f66
--- /dev/null
+++ b/app/views/statuses/_author.html.haml
@@ -0,0 +1,20 @@
+:ruby
+ detailed ||= false
+ inline ||= false
+
+.p-author.h-card
+ = link_to ActivityPub::TagManager.instance.url_for(author), class: "#{ detailed ? 'detailed-' : '' }status__display-name u-url", target: stream_link_target, rel: "noopener #{ (detailed || inline) ? '' : 'noreferrer' }" do
+ %div{ class: "#{ detailed ? 'detailed-' : '' }status__#{ detailed ? 'display-' : '' }avatar" }
+ %div
+ - if current_account&.user&.setting_auto_play_gif || autoplay
+ = image_tag author.avatar_original_url, alt: '', class: 'u-photo account__avatar'
+ - else
+ = image_tag author.avatar_static_url, alt: '', class: 'u-photo account__avatar'
+ %span.display-name
+ %bdi
+ %strong.display-name__html.p-name.emojify= display_name(author, custom_emojify: true, autoplay: autoplay)
+
+ %span.display-name__account
+ = acct(author)
+ - unless inline
+ = fa_icon('lock') if author.locked?
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index 4c879472d..8d800a61a 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -1,45 +1,14 @@
.detailed-status.detailed-status--flex{ class: "detailed-status-#{status.visibility}" }
- .p-author.h-card
- = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do
- .detailed-status__display-avatar
- - if current_account&.user&.setting_auto_play_gif || autoplay
- = image_tag status.account.avatar_original_url, alt: '', class: 'account__avatar u-photo'
- - else
- = image_tag status.account.avatar_static_url, alt: '', class: 'account__avatar u-photo'
- %span.display-name
- %bdi
- %strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: autoplay)
- %span.display-name__account
- = acct(status.account)
- = fa_icon('lock') if status.account.locked?
+ = render 'statuses/author', author: status.account, autoplay: autoplay, detailed: true
= account_action_button(status.account)
- .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- - if status.spoiler_text?
- %p<
- %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
- %button.status__content__spoiler-link= t('statuses.show_more')
- .e-content
- = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
- - if status.preloadable_poll
- = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
- = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
+ = render 'statuses/text', status: status, autoplay: autoplay
- - if !status.media_attachments.empty?
- - if status.media_attachments.first.video?
- - video = status.media_attachments.first
- = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 670, height: 380, detailed: true, inline: true, alt: video.description do
- = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- - elsif status.media_attachments.first.audio?
- - audio = status.media_attachments.first
- = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
- = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- - else
- = react_component :media_gallery, height: 380, sensitive: sensitized?(status, current_account), standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
- = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- - elsif status.preview_card
- = react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+ - if status.quote?
+ = render 'statuses/quote', status: status.quote, autoplay: autoplay
+
+ = render 'statuses/media', status: status, autoplay: autoplay, detailed: true
.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/statuses/_media.html.haml b/app/views/statuses/_media.html.haml
new file mode 100644
index 000000000..192186979
--- /dev/null
+++ b/app/views/statuses/_media.html.haml
@@ -0,0 +1,18 @@
+:ruby
+ detailed ||= false
+ quote ||= false
+
+- if !status.media_attachments.empty?
+ - if status.media_attachments.first.video?
+ - video = status.media_attachments.first
+ = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: detailed ? 670 : 610, height: detailed ? 380 : 343, detailed: detailed, inline: true, quote: quote, alt: video.description do
+ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ - elsif status.media_attachments.first.audio?
+ - audio = status.media_attachments.first
+ = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: detailed ? 670 : 610, height: detailed ? 380 : 343, quote: quote, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
+ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+ - else
+ = react_component :media_gallery, height: detailed ? 380 : 343, sensitive: sensitized?(status, current_account), standalone: detailed, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: quote do
+ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
+- elsif status.preview_card
+ = react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true
diff --git a/app/views/statuses/_quote.html.haml b/app/views/statuses/_quote.html.haml
new file mode 100644
index 000000000..6f7416495
--- /dev/null
+++ b/app/views/statuses/_quote.html.haml
@@ -0,0 +1,6 @@
+.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) }
+ = render 'statuses/author', author: status.account, autoplay: autoplay, inline: true
+
+ = render 'statuses/text', status: status, autoplay: autoplay
+
+ = render 'statuses/media', status: status, autoplay: autoplay, quote: true
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 236948a84..d8ba0ae28 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -9,46 +9,14 @@
%time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
%data.dt-published{ value: status.created_at.to_time.iso8601 }
- .p-author.h-card
- = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
- .status__avatar
- %div
- - if current_account&.user&.setting_auto_play_gif || autoplay
- = image_tag status.account.avatar_original_url, alt: '', class: 'u-photo account__avatar'
- - else
- = image_tag status.account.avatar_static_url, alt: '', class: 'u-photo account__avatar'
- %span.display-name
- %bdi
- %strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: autoplay)
- = ' '
- %span.display-name__account
- = acct(status.account)
- = fa_icon('lock') if status.account.locked?
- .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- - if status.spoiler_text?
- %p<
- %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
- %button.status__content__spoiler-link= t('statuses.show_more')
- .e-content
- = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
- - if status.preloadable_poll
- = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
- = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
+ = render 'statuses/author', author: status.account, autoplay: autoplay
- - if !status.media_attachments.empty?
- - if status.media_attachments.first.video?
- - video = status.media_attachments.first
- = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 610, height: 343, inline: true, alt: video.description do
- = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- - elsif status.media_attachments.first.audio?
- - audio = status.media_attachments.first
- = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
- = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- - else
- = react_component :media_gallery, height: 343, sensitive: sensitized?(status, current_account), autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
- = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- - elsif status.preview_card
- = react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+ = render 'statuses/text', status: status, autoplay: autoplay
+
+ - if status.quote?
+ = render 'statuses/quote', status: status.quote, autoplay: autoplay
+
+ = render 'statuses/media', status: status, autoplay: autoplay
- if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id && !hide_show_thread
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
diff --git a/app/views/statuses/_text.html.haml b/app/views/statuses/_text.html.haml
new file mode 100644
index 000000000..6601a2bf6
--- /dev/null
+++ b/app/views/statuses/_text.html.haml
@@ -0,0 +1,10 @@
+.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
+ - if status.spoiler_text?
+ %p<
+ %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
+ %button.status__content__spoiler-link= t('statuses.show_more')
+ .e-content
+ = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
+ - if status.preloadable_poll
+ = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
+ = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
diff --git a/db/migrate/20180419235016_add_quote_id_to_statuses.rb b/db/migrate/20180419235016_add_quote_id_to_statuses.rb
new file mode 100644
index 000000000..d7550b248
--- /dev/null
+++ b/db/migrate/20180419235016_add_quote_id_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddQuoteIdToStatuses < ActiveRecord::Migration[5.1]
+ def change
+ add_column :statuses, :quote_id, :bigint, null: true, default: nil
+ end
+end
diff --git a/db/migrate/20200301102028_add_index_to_statuses_quote_id.rb b/db/migrate/20200301102028_add_index_to_statuses_quote_id.rb
new file mode 100644
index 000000000..45293db98
--- /dev/null
+++ b/db/migrate/20200301102028_add_index_to_statuses_quote_id.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddIndexToStatusesQuoteId < ActiveRecord::Migration[5.2]
+ disable_ddl_transaction!
+
+ def change
+ add_index :statuses, :quote_id, algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 18bf1d4ca..c9002cbb7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -803,6 +803,7 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
t.bigint "account_id", null: false
t.bigint "application_id"
t.bigint "in_reply_to_account_id"
+ t.bigint "quote_id"
t.bigint "poll_id"
t.datetime "deleted_at"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
@@ -810,6 +811,7 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
+ t.index ["quote_id"], name: "index_statuses_on_quote_id"
t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
t.index ["uri"], name: "index_statuses_on_uri", unique: true
end