diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 38c9f5a20..3799c3a5e 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 0cc231840..8bc616216 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 2a873696c..194ca0266 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 9a8c0c161..ae7035916 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -44,10 +44,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
@@ -64,7 +67,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
@@ -128,6 +132,7 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:language,
:scheduled_at,
+ :quote_id,
media_ids: [],
media_attributes: [
:id,
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/helpers/context_helper.rb b/app/helpers/context_helper.rb
index 08cfa9c6d..9303add35 100644
--- a/app/helpers/context_helper.rb
+++ b/app/helpers/context_helper.rb
@@ -23,6 +23,7 @@ module ContextHelper
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
+ quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
}.freeze
def full_context
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
index c70931489..7b9f23ac1 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -15,7 +15,12 @@ module FormattingHelper
module_function :extract_status_plain_text
def status_content_format(status)
- html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
+ html_aware_format(
+ status.text,
+ status.local?,
+ preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []),
+ quote: status.respond_to?(:quote) && status.quote,
+ )
end
def rss_status_content_format(status)
diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js
index 3c8eec546..44e6c5748 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 3756a975b..9a2dd74ab 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -22,6 +22,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';
@@ -120,6 +122,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,
@@ -193,6 +212,7 @@ export function submitCompose(routerHistory) {
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
+ 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 7388e0c58..def7a068d 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 9c69be601..a1dadff52 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));
}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 8a22f83fa..d445f436e 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -75,6 +75,8 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
normalStatus.hidden = normalOldStatus.get('hidden');
+ normalStatus.quote = normalOldStatus.get('quote');
+ normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
} else {
// If the status has a CW but no contents, treat the CW as if it were the
// status' contents, to avoid having a CW toggle with seemingly no effect.
@@ -91,6 +93,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/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 275280a53..7b8c9add3 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 const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
@@ -347,3 +350,25 @@ export const undoStatusTranslation = id => ({
type: STATUS_TRANSLATE_UNDO,
id,
});
+
+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 4f772a55f..db86d105a 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';
@@ -39,6 +41,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,
@@ -111,6 +114,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 659a83375..f075a1fd3 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 6b8922608..a5556b027 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';
@@ -22,6 +23,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({
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});
-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 (
+
+ );
+ }
+
+ return (
+
+ );
+ })();
+
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+
+ {identity(quoteStatus, null, null, true)}
+
+
+ {media(quoteStatus, true)}
+
+ );
+ })();
+
+ return (
+
+ {quoteInner}
+
+ );
+};
+
+export default @connect(mapStateToProps) @injectIntl
class Status extends ImmutablePureComponent {
static contextTypes = {
@@ -87,7 +165,9 @@ class Status extends ImmutablePureComponent {
onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
+ onQuoteToggleHidden: PropTypes.func,
muted: PropTypes.bool,
+ quoteMuted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
@@ -103,6 +183,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
+ contextType: PropTypes.string,
};
// Avoid checking props that are functions (and whose equality will always
@@ -114,10 +195,12 @@ class Status extends ImmutablePureComponent {
'hidden',
'unread',
'pictureInPicture',
+ 'quoteMuted',
];
state = {
showMedia: defaultMediaVisibility(this.props.status),
+ showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
statusId: undefined,
forceFilter: undefined,
};
@@ -126,6 +209,7 @@ class Status extends ImmutablePureComponent {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status ? nextProps.status.get('quote', null) : null),
statusId: nextProps.status.get('id'),
};
} else {
@@ -137,6 +221,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
};
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
@@ -165,6 +253,15 @@ class Status extends ImmutablePureComponent {
this._openProfile(proper);
};
+ handleQuoteClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
+ }
+
handleExpandedToggle = () => {
this.props.onToggleHidden(this._properStatus());
};
@@ -177,6 +274,10 @@ class Status extends ImmutablePureComponent {
this.props.onTranslate(this._properStatus());
};
+ handleExpandedQuoteToggle = () => {
+ this.props.onQuoteToggleHidden(this._properStatus());
+ }
+
renderLoadingMediaGallery () {
return ;
}
@@ -309,10 +410,9 @@ class Status extends ImmutablePureComponent {
};
render () {
- let media = null;
- let statusAvatar, prepend, rebloggedByText;
+ let prepend, rebloggedByText;
- const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture } = this.props;
+ const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, quoteMuted, contextType } = this.props;
let { status, account, ...other } = this.props;
@@ -397,103 +497,123 @@ class Status extends ImmutablePureComponent {
);
}
- if (pictureInPicture.get('inUse')) {
- media = ;
- } else if (status.get('media_attachments').size > 0) {
- if (this.props.muted) {
- media = (
- {
+ if (pictureInPicture.get('inUse')) {
+ return ;
+ } else if (status.get('media_attachments').size > 0) {
+ if (this.props.muted) {
+ return (
+
+ );
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = status.getIn(['media_attachments', 0]);
+
+ return (
+
+ {Component => (
+
+ )}
+
+ );
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = status.getIn(['media_attachments', 0]);
+
+ return (
+
+ {Component => (
+
+ )}
+
+ );
+ } else {
+ return (
+
+ {Component => (
+
+ )}
+
+ );
+ }
+ } else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) {
+ return (
+
);
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
- const attachment = status.getIn(['media_attachments', 0]);
-
- media = (
-
- {Component => (
-
- )}
-
- );
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- const attachment = status.getIn(['media_attachments', 0]);
-
- media = (
-
- {Component => (
-
- )}
-
- );
- } else {
- media = (
-
- {Component => (
-
- )}
-
- );
}
- } else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) {
- media = (
-
- );
- }
- if (account === undefined || account === null) {
- statusAvatar = ;
- } else {
- statusAvatar = ;
- }
+ return null;
+ };
+
+ const statusAvatar = (status, account) => {
+ if (account === undefined || account === null) {
+ return ;
+ } else {
+ return ;
+ }
+ };
+
+ const identity = (status, account) => (
+
+
+ {statusAvatar(status, account)}
+
+
+
+
+ );
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
@@ -516,13 +636,7 @@ class Status extends ImmutablePureComponent {
{status.get('edited_at') && *}
-
-
- {statusAvatar}
-
-
-
-
+ {identity(status, account, false)}
- {media}
+ {media(status)}
+
+ {quote(status, this.props.muted, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.context.router, contextType)}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index eeb376561..eae807b5d 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -26,6 +26,8 @@ const messages = defineMessages({
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
@@ -68,6 +70,7 @@ class StatusActionBar extends ImmutablePureComponent {
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
+ onQuote: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
@@ -138,6 +141,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status, this.context.router.history);
+ }
+
handleBookmarkClick = () => {
this.props.onBookmark(this.props.status);
};
@@ -231,6 +238,14 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onFilter();
};
+ static quoteTitle = (intl, messages, publicStatus) => {
+ if (publicStatus) {
+ return intl.formatMessage(messages.quote);
+ } else {
+ return intl.formatMessage(messages.cannot_quote);
+ }
+ }
+
render () {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.context.identity;
@@ -361,7 +376,8 @@ class StatusActionBar extends ImmutablePureComponent {
-
+
+
{shareButton}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index ece54621f..1d2e6bc41 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -9,6 +9,7 @@ import Icon from 'mastodon/components/icon';
import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
+const QUOTE_MAX_HEIGHT = 112; // 22px * 5 (+ 2px padding at the top)
class TranslateButton extends React.PureComponent {
@@ -64,6 +65,7 @@ class StatusContent extends React.PureComponent {
collapsable: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
intl: PropTypes.object,
+ quote: PropTypes.bool,
};
state = {
@@ -107,12 +109,12 @@ class StatusContent extends React.PureComponent {
}
if (status.get('collapsed', null) === null && onCollapsedToggle) {
- const { collapsable, onClick } = this.props;
+ const { collapsable, onClick, quote } = this.props;
const collapsed =
collapsable
&& onClick
- && node.clientHeight > MAX_HEIGHT
+ && node.clientHeight > (quote ? QUOTE_MAX_HEIGHT : MAX_HEIGHT)
&& status.get('spoiler_text').length === 0;
onCollapsedToggle(collapsed);
@@ -216,7 +218,7 @@ class StatusContent extends React.PureComponent {
};
render () {
- const { status, intl } = this.props;
+ const { status, intl, quote } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');
@@ -245,6 +247,12 @@ class StatusContent extends React.PureComponent {
);
+ if (quote) {
+ const doc = new DOMParser().parseFromString(content.__html, 'text/html').documentElement;
+ Array.from(doc.querySelectorAll('br')).forEach(nl => nl.replaceWith(' '));
+ content.__html = doc.outerHTML;
+ }
+
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 580f409e9..ea52c9186 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -4,6 +4,7 @@ import Status from '../components/status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../actions/compose';
@@ -27,6 +28,8 @@ import {
editStatus,
translateStatus,
undoStatusTranslation,
+ hideQuote,
+ revealQuote,
} from '../actions/statuses';
import {
unmuteAccount,
@@ -58,6 +61,8 @@ const messages = defineMessages({
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+ quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
+ quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
});
@@ -107,6 +112,22 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
+ onQuote (status, router) {
+ dispatch((_, getState) => {
+ let state = getState();
+
+ if (state.getIn(['compose', 'text']).trim().length !== 0) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.quoteMessage),
+ confirm: intl.formatMessage(messages.quoteConfirm),
+ onConfirm: () => dispatch(quoteCompose(status, router)),
+ }));
+ } else {
+ dispatch(quoteCompose(status, router));
+ }
+ });
+ },
+
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
@@ -234,6 +255,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
},
+ onQuoteToggleHidden (status) {
+ if (status.get('quote_hidden')) {
+ dispatch(revealQuote(status.get('id')));
+ } else {
+ dispatch(hideQuote(status.get('id')));
+ }
+ },
+
onBlockDomain (domain) {
dispatch(openModal('CONFIRM', {
message:
{domain} }} />,
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index a55658360..461834848 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -50,6 +50,7 @@ class Audio extends React.PureComponent {
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
+ quote: PropTypes.bool,
};
state = {
@@ -94,7 +95,11 @@ class Audio extends React.PureComponent {
_setDimensions () {
const width = this.player.offsetWidth;
- const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
+ let height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
+
+ if (this.props.quote) {
+ height /= 2;
+ }
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 8da737ec0..cf3ff8aec 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -235,6 +235,7 @@ class ComposeForm extends ImmutablePureComponent {
+
{
@@ -37,7 +39,7 @@ class ReplyIndicator extends ImmutablePureComponent {
};
render () {
- const { status, intl } = this.props;
+ const { status, intl, quote } = this.props;
if (!status) {
return null;
@@ -46,7 +48,7 @@ class ReplyIndicator extends ImmutablePureComponent {
const content = { __html: status.get('contentHtml') };
return (
-
+
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
index a1302b2d4..2eea64f96 100644
--- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
+++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
@@ -1,22 +1,23 @@
import { connect } from 'react-redux';
-import { cancelReplyCompose } from '../../../actions/compose';
+import { cancelReplyCompose, cancelQuoteCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
- const mapStateToProps = state => {
+ const mapStateToProps = (state, props) => {
let statusId = state.getIn(['compose', 'id'], null);
let editing = true;
if (statusId === null) {
- statusId = state.getIn(['compose', 'in_reply_to']);
+ statusId = state.getIn(['compose', props.quote ? 'quote_from' : 'in_reply_to']);
editing = false;
}
return {
status: getStatus(state, { id: statusId }),
+ quote: props.quote,
editing,
};
};
@@ -26,8 +27,8 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = dispatch => ({
- onCancel () {
- dispatch(cancelReplyCompose());
+ onCancel (quote) {
+ dispatch(quote ? cancelQuoteCompose() : cancelReplyCompose());
},
});
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
index 3f59b891b..f09a57be3 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -4,10 +4,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button';
+import StatusActionBar from 'mastodon/components/status_action_bar';
import classNames from 'classnames';
import { me, boostModal } from 'mastodon/initial_state';
import { defineMessages, injectIntl } from 'react-intl';
-import { replyCompose } from 'mastodon/actions/compose';
+import { replyCompose, quoteCompose } from 'mastodon/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
import { makeGetStatus } from 'mastodon/selectors';
import { initBoostModal } from 'mastodon/actions/boosts';
@@ -20,9 +21,13 @@ const messages = defineMessages({
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+ quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
+ quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
});
@@ -135,6 +140,31 @@ class Footer extends ImmutablePureComponent {
}
};
+ _performQuote = () => {
+ const { dispatch, status, onClose } = this.props;
+ const { router } = this.context;
+
+ if (onClose) {
+ onClose();
+ }
+
+ dispatch(quoteCompose(status, router.history));
+ }
+
+ handleQuoteClick = () => {
+ const { dispatch, askReplyConfirmation, intl } = this.props;
+
+ if (askReplyConfirmation) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.quoteMessage),
+ confirm: intl.formatMessage(messages.quoteConfirm),
+ onConfirm: this._performQuote,
+ }));
+ } else {
+ this._performQuote();
+ }
+ }
+
handleOpenClick = e => {
const { router } = this.context;
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 0d4767331..d183aaa75 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import IconButton from '../../../components/icon_button';
+import StatusActionBar from '../../../components/status_action_bar';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
@@ -20,6 +21,8 @@ const messages = defineMessages({
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
@@ -61,6 +64,7 @@ class ActionBar extends React.PureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
+ onQuote: PropTypes.func,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
@@ -88,6 +92,10 @@ class ActionBar extends React.PureComponent {
this.props.onReblog(this.props.status, e);
};
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status, this.context.router.history);
+ }
+
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
};
@@ -286,6 +294,7 @@ class ActionBar extends React.PureComponent {
+
{shareButton}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 34fac1010..a7394c5b4 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -68,6 +68,7 @@ export default class Card extends React.PureComponent {
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
+ quote: PropTypes.bool,
};
static defaultProps = {
@@ -184,7 +185,7 @@ export default class Card extends React.PureComponent {
}
render () {
- const { card, maxDescription, compact } = this.props;
+ const { card, maxDescription, compact, quote } = this.props;
const { width, embedded, revealed } = this.state;
if (card === null) {
@@ -197,7 +198,11 @@ export default class Card extends React.PureComponent {
const className = classnames('status-card', { horizontal, compact, interactive });
const title = interactive ?
{card.get('title')} :
{card.get('title')};
const ratio = card.get('width') / card.get('height');
- const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+ let height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+ if (quote && height) {
+ height /= 2;
+ }
const description = (
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 116d9f6b2..34f734cea 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -1,8 +1,10 @@
import React from 'react';
+import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
+import { mapStateToProps, quote } from '../../../components/status';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom';
@@ -25,7 +27,7 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
-export default @injectIntl
+export default @connect(mapStateToProps) @injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
@@ -37,17 +39,21 @@ class DetailedStatus extends ImmutablePureComponent {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
+ onQuoteToggleHidden: PropTypes.func.isRequired,
onTranslate: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired,
compact: PropTypes.bool,
+ quoteMuted: PropTypes.bool,
showMedia: PropTypes.bool,
+ showQuoteMedia: PropTypes.bool,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
+ onQuoteToggleMediaVisibility: PropTypes.func,
};
state = {
@@ -56,8 +62,9 @@ class DetailedStatus extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
+ const acct = e.currentTarget.getAttribute('data-acct');
e.preventDefault();
- this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
+ this.context.router.history.push(`/@${acct}`);
}
e.stopPropagation();
@@ -71,6 +78,19 @@ class DetailedStatus extends ImmutablePureComponent {
this.props.onToggleHidden(this.props.status);
};
+ handleExpandedQuoteToggle = () => {
+ this.props.onQuoteToggleHidden(this.props.status);
+ }
+
+ handleQuoteClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
+ }
+
_measureHeight (heightJustChanged) {
if (this.props.measureHeight && this.node) {
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
@@ -112,13 +132,12 @@ class DetailedStatus extends ImmutablePureComponent {
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };
- const { intl, compact, pictureInPicture } = this.props;
+ const { intl, compact, pictureInPicture, quoteMuted } = this.props;
if (!status) {
return null;
}
- let media = '';
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
@@ -129,63 +148,84 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
- if (pictureInPicture.get('inUse')) {
- media =
;
- } else if (status.get('media_attachments').size > 0) {
- if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
- const attachment = status.getIn(['media_attachments', 0]);
+ const identity = (status, _0, _1, quote = false) => (
+
+
+
+
+ );
- media = (
-
- );
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- const attachment = status.getIn(['media_attachments', 0]);
+ const media = (status, quote = false) => {
+ if (pictureInPicture.get('inUse')) {
+ return
;
+ } else if (status.get('media_attachments').size > 0) {
+ if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = status.getIn(['media_attachments', 0]);
- media = (
-
+ );
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = status.getIn(['media_attachments', 0]);
+
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ } else if (status.get('spoiler_text').length === 0) {
+ return (
+
- );
- } else {
- media = (
-
);
}
- } else if (status.get('spoiler_text').length === 0) {
- media =
;
- }
+
+ return null;
+ };
if (status.get('application')) {
applicationLink =
· {status.getIn(['application', 'name'])};
@@ -261,10 +301,7 @@ class DetailedStatus extends ImmutablePureComponent {
return (
-
-
-
-
+ {identity(status, null, null, false)}
- {media}
+ {media(status, false)}
+
+ {quote(status, false, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.context.router)}
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
index bfed16620..2c2c7c8de 100644
--- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -20,6 +20,8 @@ import {
deleteStatus,
hideStatus,
revealStatus,
+ hideQuote,
+ revealQuote,
} from '../../../actions/statuses';
import { initMuteModal } from '../../../actions/mutes';
import { initBlockModal } from '../../../actions/blocks';
@@ -165,6 +167,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
+ onQuoteToggleHidden (status) {
+ if (status.get('quote_hidden')) {
+ dispatch(revealQuote(status.get('id')));
+ } else {
+ dispatch(hideQuote(status.get('id')));
+ }
+ },
+
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 38bbc6895..10e454428 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -23,6 +23,7 @@ import {
} from '../../actions/interactions';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@@ -35,6 +36,8 @@ import {
revealStatus,
translateStatus,
undoStatusTranslation,
+ hideQuote,
+ revealQuote,
} from '../../actions/statuses';
import {
unblockAccount,
@@ -73,6 +76,8 @@ const messages = defineMessages({
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+ quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
+ quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
});
@@ -205,6 +210,7 @@ class Status extends ImmutablePureComponent {
state = {
fullscreen: false,
showMedia: defaultMediaVisibility(this.props.status),
+ showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
loadedStatusId: undefined,
};
@@ -227,7 +233,11 @@ class Status extends ImmutablePureComponent {
}
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
- this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
+ this.setState({
+ showMedia: defaultMediaVisibility(nextProps.status),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
+ loadedStatusId: nextProps.status.get('id'),
+ });
}
}
@@ -235,6 +245,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
};
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
handleFavouriteClick = (status) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
@@ -285,6 +299,19 @@ class Status extends ImmutablePureComponent {
}
};
+ handleQuoteClick = (status) => {
+ let { askReplyConfirmation, dispatch, intl } = this.props;
+ if (askReplyConfirmation) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.quoteMessage),
+ confirm: intl.formatMessage(messages.quoteConfirm),
+ onConfirm: () => dispatch(quoteCompose(status, this.context.router.history)),
+ }));
+ } else {
+ dispatch(quoteCompose(status, this.context.router.history));
+ }
+ }
+
handleModalReblog = (status, privacy) => {
this.props.dispatch(reblog(status, privacy));
};
@@ -388,6 +415,14 @@ class Status extends ImmutablePureComponent {
}
};
+ handleQuoteToggleHidden = (status) => {
+ if (status.get('quote_hidden')) {
+ this.props.dispatch(revealQuote(status.get('id')));
+ } else {
+ this.props.dispatch(hideQuote(status.get('id')));
+ }
+ }
+
handleToggleAll = () => {
const { status, ancestorsIds, descendantsIds } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
@@ -640,9 +675,12 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia}
onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate}
+ onQuoteToggleHidden={this.handleQuoteToggleHidden}
domain={domain}
showMedia={this.state.showMedia}
+ showQuoteMedia={this.state.showQuoteMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
+ onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
pictureInPicture={pictureInPicture}
/>
@@ -652,6 +690,7 @@ class Status extends ImmutablePureComponent {
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
+ onQuote={this.handleQuoteClick}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick}
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 8d63394aa..14dc98e25 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -121,6 +121,7 @@ class Video extends React.PureComponent {
autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
+ quote: PropTypes.bool,
componentIndex: PropTypes.number,
autoFocus: PropTypes.bool,
};
@@ -524,7 +525,7 @@ class Video extends React.PureComponent {
}
render () {
- const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
+ const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, autoFocus, quote } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100);
const playerStyle = {};
@@ -538,6 +539,11 @@ class Video extends React.PureComponent {
playerStyle.height = height;
}
+ if (quote && height) {
+ height /= 2;
+ playerStyle.height = height;
+ }
+
let preload;
if (this.props.currentTime || fullscreen || dragging) {
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index c09411f65..7f0ac878c 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -607,6 +607,14 @@
"defaultMessage": "This post cannot be boosted",
"id": "status.cannot_reblog"
},
+ {
+ "defaultMessage": "Quote",
+ "id": "status.quote"
+ },
+ {
+ "defaultMessage": "This post cannot be quoted",
+ "id": "status.cannot_quote"
+ },
{
"defaultMessage": "Favourite",
"id": "status.favourite"
@@ -764,6 +772,14 @@
{
"defaultMessage": "Replied to {name}",
"id": "status.replied_to"
+ },
+ {
+ "defaultMessage": "Muted quote",
+ "id": "status.muted_quote"
+ },
+ {
+ "defaultMessage": "Unlisted quote",
+ "id": "status.unlisted_quote"
}
],
"path": "app/javascript/mastodon/components/status.json"
@@ -3716,6 +3732,14 @@
"defaultMessage": "Detailed conversation view",
"id": "status.detailed_status"
},
+ {
+ "defaultMessage": "Quote",
+ "id": "confirmations.quote.confirm"
+ },
+ {
+ "defaultMessage": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
+ "id": "confirmations.quote.message"
+ },
{
"defaultMessage": "Reply",
"id": "confirmations.reply.confirm"
@@ -4335,4 +4359,4 @@
],
"path": "app/javascript/mastodon/features/video/index.json"
}
-]
\ No newline at end of file
+]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index f4d051940..14a5ad4b5 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -169,6 +169,8 @@
"confirmations.mute.confirm": "Mute",
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
+ "confirmations.quote.confirm": "Quote",
+ "confirmations.quote.message": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.reply.confirm": "Reply",
@@ -553,6 +555,7 @@
"status.block": "Block @{name}",
"status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost",
+ "status.cannot_quote": "This post cannot be quoted",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to post",
"status.delete": "Delete",
@@ -574,9 +577,11 @@
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
+ "status.muted_quote": "Muted quote",
"status.open": "Expand this post",
"status.pin": "Pin on profile",
"status.pinned": "Pinned post",
+ "status.quote": "Quote",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",
@@ -599,6 +604,7 @@
"status.translate": "Translate",
"status.translated_from_with": "Translated from {lang} using {provider}",
"status.uncached_media_warning": "Not available",
+ "status.unlisted_quote": "Unlisted quote",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index a9894cfff..5da9c4262 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -170,6 +170,8 @@
"confirmations.mute.confirm": "ミュート",
"confirmations.mute.explanation": "これにより相手のトゥートと返信は見えなくなりますが、相手はあなたをフォローし続けトゥートを見ることができます。",
"confirmations.mute.message": "本当に{name}さんをミュートしますか?",
+ "confirmations.quote.confirm": "引用",
+ "confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.redraft.confirm": "削除して下書きに戻す",
"confirmations.redraft.message": "本当にこのトゥートを削除して下書きに戻しますか? このトゥートへのお気に入り登録やブーストは失われ、返信は孤立することになります。",
"confirmations.reply.confirm": "返信",
@@ -556,6 +558,7 @@
"status.cancel_reblog_private": "ブースト解除",
"status.cannot_reblog": "このトゥートはブーストできません",
"status.copy": "トゥートへのリンクをコピー",
+ "status.cannot_quote": "このトゥートは引用できません",
"status.delete": "削除",
"status.detailed_status": "詳細な会話ビュー",
"status.direct": "@{name}さんにダイレクトメッセージ",
@@ -575,9 +578,11 @@
"status.more": "もっと見る",
"status.mute": "@{name}さんをミュート",
"status.mute_conversation": "会話をミュート",
+ "status.muted_quote": "ミュートされた引用",
"status.open": "詳細を表示",
"status.pin": "プロフィールに固定表示",
"status.pinned": "固定されたトゥート",
+ "status.quote": "引用",
"status.read_more": "もっと見る",
"status.reblog": "ブースト",
"status.reblog_private": "ブースト",
@@ -600,6 +605,7 @@
"status.translate": "翻訳",
"status.translated_from_with": "{provider}を使って{lang}から翻訳",
"status.uncached_media_warning": "利用できません",
+ "status.unlisted_quote": "未収載の引用",
"status.unmute_conversation": "会話のミュートを解除",
"status.unpin": "プロフィールへの固定を解除",
"subscribed_languages.lead": "選択した言語のトゥートだけがホームとリストのタイムラインに表示されます。全ての言語のトゥートを受け取る場合は全てのチェックを外して下さい。",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 842b7af51..592417e4e 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -4,6 +4,8 @@ import {
COMPOSE_CHANGE,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
+ COMPOSE_QUOTE,
+ COMPOSE_QUOTE_CANCEL,
COMPOSE_DIRECT,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
@@ -67,6 +69,8 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
+ quote_from: null,
+ quote_from_url: null,
is_composing: false,
is_submitting: false,
is_changing_upload: false,
@@ -119,6 +123,8 @@ function clearAll(state) {
map.set('is_submitting', false);
map.set('is_changing_upload', false);
map.set('in_reply_to', null);
+ map.set('quote_from', null);
+ map.set('quote_from_url', null);
map.set('privacy', state.get('default_privacy'));
map.set('sensitive', state.get('default_sensitive'));
map.set('language', state.get('default_language'));
@@ -250,6 +256,17 @@ const expiresInFromExpiresAt = expires_at => {
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
};
+const rejectQuoteAltText = html => {
+ const fragment = domParser.parseFromString(html, 'text/html').documentElement;
+
+ const quote_inline = fragment.querySelector('span.quote-inline');
+ if (quote_inline) {
+ quote_inline.remove();
+ }
+
+ return fragment.innerHTML;
+};
+
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
prefix = prefix.toLowerCase();
if (suggestions.length < 4) {
@@ -323,10 +340,20 @@ export default function compose(state = initialState, action) {
case COMPOSE_COMPOSING_CHANGE:
return state.set('is_composing', action.value);
case COMPOSE_REPLY:
+ case COMPOSE_QUOTE:
return state.withMutations(map => {
map.set('id', null);
- map.set('in_reply_to', action.status.get('id'));
- map.set('text', statusToTextMentions(state, action.status));
+ if (action.type === COMPOSE_REPLY) {
+ map.set('in_reply_to', action.status.get('id'));
+ map.set('quote_from', null);
+ map.set('quote_from_url', null);
+ map.set('text', statusToTextMentions(state, action.status));
+ } else {
+ map.set('in_reply_to', null);
+ map.set('quote_from', action.status.get('id'));
+ map.set('quote_from_url', action.status.get('url'));
+ map.set('text', '');
+ }
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('focusDate', new Date());
map.set('caretPosition', null);
@@ -358,6 +385,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true);
case COMPOSE_REPLY_CANCEL:
+ case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
@@ -456,8 +484,10 @@ export default function compose(state = initialState, action) {
}));
case REDRAFT:
return state.withMutations(map => {
- map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
+ map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status))));
map.set('in_reply_to', action.status.get('in_reply_to_id'));
+ map.set('quote_from', action.status.getIn(['quote', 'id']));
+ map.set('quote_from_url', action.status.getIn(['quote', 'url']));
map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true)));
map.set('focusDate', new Date());
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index d3e71da9d..85e749531 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -10,6 +10,7 @@ import {
import {
COMPOSE_MENTION,
COMPOSE_REPLY,
+ COMPOSE_QUOTE,
COMPOSE_DIRECT,
} from '../actions/compose';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
@@ -37,6 +38,7 @@ export default function search(state = initialState, action) {
case SEARCH_SHOW:
return state.set('hidden', false);
case COMPOSE_REPLY:
+ case COMPOSE_QUOTE:
case COMPOSE_MENTION:
case COMPOSE_DIRECT:
return state.set('hidden', true);
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index a39ac6b64..913c9b37c 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -17,6 +17,8 @@ import {
STATUS_TRANSLATE_UNDO,
STATUS_FETCH_REQUEST,
STATUS_FETCH_FAIL,
+ QUOTE_REVEAL,
+ QUOTE_HIDE,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
@@ -83,6 +85,14 @@ export default function statuses(state = initialState, action) {
});
case STATUS_COLLAPSE:
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
+ case QUOTE_REVEAL:
+ return state.withMutations(map => {
+ action.ids.forEach(id => map.setIn([id, 'quote_hidden'], false));
+ });
+ case QUOTE_HIDE:
+ return state.withMutations(map => {
+ action.ids.forEach(id => map.setIn([id, 'quote_hidden'], true));
+ });
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case STATUS_TRANSLATE_SUCCESS:
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index bf46c810e..c19384d50 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -2,6 +2,7 @@ import { createSelector } from 'reselect';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { toServerSideType } from 'mastodon/utils/filters';
import { me } from '../initial_state';
+import {reblogRequest} from '../actions/interactions';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
@@ -35,22 +36,54 @@ export const makeGetStatus = () => {
[
(state, { id }) => state.getIn(['statuses', id]),
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+ (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+ (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account'])]),
+ (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote', 'account'])]),
+ (state, { id }) => state.getIn(['relationships', state.getIn(['statuses', id, 'account'])]),
+ (state, { id }) => state.getIn(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+ (state, { id }) => state.getIn(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account'])]),
+ (state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', id, 'account']), 'moved'])]),
+ (state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account']), 'moved'])]),
+ (state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account']), 'moved'])]),
getFilters,
],
- (statusBase, statusReblog, accountBase, accountReblog, filters) => {
+ (statusBase, statusReblog, statusQuote, accountBase, accountReblog, accountQuote, accountReblogQuote, relationshipBase, relationshipReblog, relationshipQuote, movedBase, movedReblog, movedQuote, filters) => {
if (!statusBase || statusBase.get('isLoading')) {
return null;
}
+ accountBase = accountBase.withMutations(map => {
+ map.set('relationship', relationshipBase);
+ map.set('moved', movedBase);
+ });
+
if (statusReblog) {
+ accountReblog = accountReblog.withMutations(map => {
+ map.set('relationship', relationshipReblog);
+ map.set('moved', movedReblog);
+ });
statusReblog = statusReblog.set('account', accountReblog);
} else {
statusReblog = null;
}
+ if (statusQuote) {
+ accountQuote = accountQuote.withMutations(map => {
+ map.set('relationship', relationshipQuote);
+ map.set('moved', movedQuote);
+ });
+ statusQuote = statusQuote.set('account', accountQuote);
+ } else {
+ statusQuote = null;
+ }
+
+ if (statusReblog && accountReblogQuote) {
+ statusReblog = statusReblog.setIn(['quote', 'account'], accountReblogQuote);
+ }
+
let filtered = false;
if ((accountReblog || accountBase).get('id') !== me && filters) {
let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
@@ -65,6 +98,7 @@ export const makeGetStatus = () => {
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
+ map.set('quote', statusQuote);
map.set('account', accountBase);
map.set('matched_filters', filtered);
});
diff --git a/app/javascript/mastodon/utils/uniq.js b/app/javascript/mastodon/utils/uniq.js
new file mode 100644
index 000000000..b1bb6f351
--- /dev/null
+++ b/app/javascript/mastodon/utils/uniq.js
@@ -0,0 +1 @@
+export const uniq = (array) => array.filter((x, i, self) => self.indexOf(x) === i);
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index a5e2014f7..76499ba56 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -322,6 +322,29 @@ function main() {
}
});
});
+
+ delegate(document, '.quote-status', 'click', ({ target }) => {
+ if (target.closest('.status__content__spoiler-link') ||
+ target.closest('.media-gallery') ||
+ target.closest('.video-player') ||
+ target.closest('.audio-player')) {
+ return false;
+ }
+
+ let url = target.closest('.quote-status').getAttribute('dataurl');
+ if (target.closest('.status__display-name')) {
+ url = target.closest('.status__display-name').getAttribute('href');
+ } else if (target.closest('.status-card')) {
+ url = target.closest('.status-card').getAttribute('href');
+ }
+
+ if (window.location.hostname === url.split('/')[2].split(':')[0]) {
+ window.location.href = url;
+ } else {
+ window.open(url, 'blank');
+ }
+ return false;
+ });
}
loadPolyfills()
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index bc87d4532..a943b3499 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -827,6 +827,10 @@ body > [data-popper-placement] {
min-height: 23px;
overflow-y: auto;
flex: 0 2 auto;
+
+ &.quote-indicator {
+ background: $success-green;
+ }
}
.reply-indicator__header {
@@ -1008,6 +1012,10 @@ body > [data-popper-placement] {
.status__content.status__content--collapsed {
max-height: 22px * 15; // 15 lines is roughly above 500 characters
+
+ .quote-status & {
+ max-height: 22px * 5;
+ }
}
.status__content__read-more-button {
@@ -1080,6 +1088,69 @@ body > [data-popper-placement] {
}
}
+.quote-status {
+ border: solid 1px $ui-base-lighter-color;
+ border-radius: 4px !important;
+ padding: 5px !important;
+ margin-top: 8px;
+ position: relative;
+
+ .muted-quote,
+ .unlisted-quote button {
+ color: $dark-text-color;
+ font-size: 15px;
+ width: 100%;
+ border: 0;
+ padding: 0;
+ }
+
+ .muted-quote {
+ text-align: center;
+ cursor: default;
+ }
+
+ .unlisted-quote button {
+ background-color: transparent;
+ cursor: pointer;
+ appearance: none;
+ }
+
+ .status__avatar,
+ .detailed-status__display-avatar {
+ position: absolute;
+ top: 5px !important;
+ left: 5px !important;
+ }
+
+ .display-name {
+ padding-left: 56px;
+ }
+
+ .detailed-status__display-name {
+ margin-bottom: 0;
+ line-height: unset;
+
+ strong,
+ span {
+ display: inline;
+ }
+ }
+
+ .status__content__text {
+ p {
+ display: inline;
+
+ &::after {
+ content: ' ';
+ }
+ }
+ }
+}
+
+.quote-inline {
+ display: none;
+}
+
.focusable {
&:focus {
outline: 0;
@@ -1095,9 +1166,12 @@ body > [data-popper-placement] {
.status {
padding: 16px;
min-height: 54px;
- border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: auto;
+ &:not(.quote-status) {
+ border-bottom: 1px solid lighten($ui-base-color, 8%);
+ }
+
@keyframes fade {
0% { opacity: 0; }
100% { opacity: 1; }
diff --git a/app/javascript/styles/mastodon/statuses.scss b/app/javascript/styles/mastodon/statuses.scss
index ce71d11e4..fe475d5c2 100644
--- a/app/javascript/styles/mastodon/statuses.scss
+++ b/app/javascript/styles/mastodon/statuses.scss
@@ -40,7 +40,7 @@
&:last-child {
.detailed-status,
- .status,
+ .status:not(.quote-status),
.load-more {
border-bottom: 0;
border-radius: 0 0 4px 4px;
@@ -63,9 +63,18 @@
}
}
+ .detailed-status .quote-status {
+ width: 100%;
+ }
+
+ .quote-status {
+ margin-top: 15px;
+ cursor: pointer;
+ }
+
@media screen and (max-width: 740px) {
.detailed-status,
- .status,
+ .status:not(.quote-status),
.load-more {
border-radius: 0 !important;
}
@@ -77,6 +86,10 @@
}
}
+.standalone-timeline .quote-status {
+ cursor: pointer;
+}
+
.button.logo-button {
flex: 0 auto;
font-size: 14px;
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 487b65223..58d094023 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -76,6 +76,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@silenced_account_ids = []
@params = {}
+ process_quote
process_status_params
process_tags
process_audience
@@ -126,6 +127,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(4).map(&:id),
poll: process_poll,
+ quote: @quote,
}
end
end
@@ -426,4 +428,21 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
poll.reload
retry
end
+
+ def process_quote
+ if (@quote = quote_from_url(@object['quoteUrl']))
+ /
RE:\s<\/span>/.match(@object['content']) do |m|
+ @object['content'] = @object['content'].sub(m[0], "#{m[0].sub(/<\/span>/, '')}")
+
+ end
+ end
+ 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
end
diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb
index 7f716f862..00d8033aa 100644
--- a/app/lib/activitypub/case_transform.rb
+++ b/app/lib/activitypub/case_transform.rb
@@ -14,6 +14,8 @@ module ActivityPub::CaseTransform
when String
camel_lower_cache[value] ||= if value.start_with?('_:')
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
+ elsif value.start_with?('_')
+ value
else
value.underscore.camelize(:lower)
end
diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb
index 48e2fc233..b42dc0f60 100644
--- a/app/lib/text_formatter.rb
+++ b/app/lib/text_formatter.rb
@@ -13,7 +13,8 @@ class TextFormatter
multiline: true,
}.freeze
- attr_reader :text, :options
+ attr_accessor :text
+ attr_reader :options
# @param [String] text
# @param [Hash] options
@@ -21,6 +22,7 @@ class TextFormatter
# @option options [Boolean] :with_domains
# @option options [Boolean] :with_rel_me
# @option options [Array] :preloaded_accounts
+ # @option options [Status] :quote
def initialize(text, options = {})
@text = text
@options = DEFAULT_OPTIONS.merge(options)
@@ -31,7 +33,7 @@ class TextFormatter
end
def to_s
- return ''.html_safe if text.blank?
+ return ''.html_safe if text.blank? & !quote?
html = rewrite do |entity|
if entity[:url]
@@ -43,6 +45,8 @@ class TextFormatter
end
end
+ html += render_quote if quote?
+
html = simple_format(html, {}, sanitize: false).delete("\n") if multiline?
html.html_safe # rubocop:disable Rails/OutputSafety
@@ -118,7 +122,7 @@ class TextFormatter
return "@#{h(entity[:screen_name])}" if account.nil?
- url = ActivityPub::TagManager.instance.url_for(account)
+ url = ap_tag_manager.url_for(account)
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
<<~HTML.squish
@@ -126,6 +130,13 @@ class TextFormatter
HTML
end
+ def render_quote
+ link = link_to_url({ url: ap_tag_manager.url_for(quote) })
+ <<~HTML.squish
+
~~~~~~~~~~
[#{link}]
+ HTML
+ end
+
def entity_cache
@entity_cache ||= EntityCache.instance
end
@@ -134,6 +145,10 @@ class TextFormatter
@tag_manager ||= TagManager.instance
end
+ def ap_tag_manager
+ @ap_tag_manager ||= ActivityPub::TagManager.instance
+ end
+
delegate :local_domain?, to: :tag_manager
def multiline?
@@ -155,4 +170,12 @@ class TextFormatter
def preloaded_accounts?
preloaded_accounts.present?
end
+
+ def quote
+ options[:quote]
+ end
+
+ def quote?
+ quote.present?
+ end
end
diff --git a/app/models/status.rb b/app/models/status.rb
index b1c49e99a..301c4be0b 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
# edited_at :datetime
@@ -59,6 +60,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
@@ -69,6 +71,7 @@ class Status < ApplicationRecord
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
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
@@ -84,6 +87,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
@@ -191,6 +195,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
@@ -255,7 +267,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 ordered_media_attachments
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 27e058199..6b98211b7 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -11,6 +11,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?
attribute :updated, if: :edited?
@@ -138,6 +141,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 e280f8eb6..5bd7680f5 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -11,7 +11,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :domain, :title, :version, :source_url, :description,
:usage, :thumbnail, :languages, :configuration,
- :registrations
+ :registrations, :feature_quote
has_one :contact, serializer: ContactSerializer
has_many :rules, serializer: REST::RuleSerializer
@@ -88,6 +88,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
}
end
+ def feature_quote
+ true
+ end
+
private
def registrations_enabled?
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index e0b8f32a6..56f33bf28 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -18,6 +18,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
@@ -42,6 +44,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
@@ -182,3 +188,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 4d55aa5e2..6eba1635e 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -74,7 +74,7 @@ class FetchLinkCardService < BaseService
@status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
else
document = Nokogiri::HTML(@status.text)
- links = document.css('a')
+ links = document.css(':not(.quote-inline) > a')
links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index bd3b69632..cf0fe7504 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -21,17 +21,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!
@@ -51,6 +54,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?
@@ -58,10 +74,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
@@ -171,6 +198,7 @@ class PostStatusService < BaseService
language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale),
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..1e5076d49
--- /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 prefers_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: prefers_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 bf498e33d..9ca84eabc 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -1,40 +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 prefers_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: prefers_autoplay?)
- %span.display-name__account
- = acct(status.account)
- = fa_icon('lock') if status.account.locked?
+ = render 'statuses/author', author: status.account, 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> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
- %button.status__content__spoiler-link= t('statuses.show_more')
- .e-content{ lang: status.language }
- = prerender_custom_emojis(status_content_format(status), status.emojis)
+ = render 'statuses/text', status: status
- - if status.preloadable_poll
- = render_poll_component(status)
+ - if status.quote?
+ = render 'statuses/quote', status: status.quote
- - if !status.ordered_media_attachments.empty?
- - if status.ordered_media_attachments.first.video?
- = render_video_component(status, width: 670, height: 380, detailed: true)
- - elsif status.ordered_media_attachments.first.audio?
- = render_audio_component(status, width: 670, height: 380)
- - else
- = render_media_gallery_component(status, height: 380, standalone: true)
- - elsif status.preview_card
- = render_card_component(status)
+ = render 'statuses/media', status: status, 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..e04a58853
--- /dev/null
+++ b/app/views/statuses/_media.html.haml
@@ -0,0 +1,13 @@
+:ruby
+ detailed ||= false
+ quote ||= false
+
+- if !status.ordered_media_attachments.empty?
+ - if status.ordered_media_attachments.first.video?
+ = render_video_component(status, width: 670, height: 380, detailed: detailed)
+ - elsif status.ordered_media_attachments.first.audio?
+ = render_audio_component(status, width: 670, height: 380)
+ - else
+ = render_media_gallery_component(status, height: 380, standalone: true)
+- elsif status.preview_card
+ = render_card_component(status, quote: quote)
diff --git a/app/views/statuses/_quote.html.haml b/app/views/statuses/_quote.html.haml
new file mode 100644
index 000000000..db0a0fb1b
--- /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, inline: true
+
+ = render 'statuses/text', status: status
+
+ = render 'statuses/media', status: status, quote: true
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 32584c92a..76c8469f0 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -12,41 +12,14 @@
*
%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 prefers_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: prefers_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> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
- %button.status__content__spoiler-link= t('statuses.show_more')
- .e-content{ lang: status.language }
- = prerender_custom_emojis(status_content_format(status), status.emojis)
+ = render 'statuses/author', author: status.account
- - if status.preloadable_poll
- = render_poll_component(status)
+ = render 'statuses/text', status: status
- - if !status.ordered_media_attachments.empty?
- - if status.ordered_media_attachments.first.video?
- = render_video_component(status, width: 610, height: 343)
- - elsif status.ordered_media_attachments.first.audio?
- = render_audio_component(status, width: 610, height: 343)
- - else
- = render_media_gallery_component(status, height: 343)
- - elsif status.preview_card
- = render_card_component(status)
+ - if status.quote?
+ = render 'statuses/quote', status: status.quote
+
+ = render 'statuses/media', status: status
- 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..1c645d7bf
--- /dev/null
+++ b/app/views/statuses/_text.html.haml
@@ -0,0 +1,9 @@
+.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> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}
+ %button.status__content__spoiler-link= t('statuses.show_more')
+ .e-content{ :lang => status.language }
+ = prerender_custom_emojis(status_content_format(status), status.emojis)
+ - if status.preloadable_poll
+ = render_poll_component(status)
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 704cef122..4390cd0cc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -935,6 +935,7 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) 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.datetime "edited_at"
@@ -947,6 +948,7 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) 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", where: "(in_reply_to_account_id IS NOT NULL)"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", where: "(in_reply_to_id IS NOT NULL)"
+ 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, opclass: :text_pattern_ops, where: "(uri IS NOT NULL)"
end
diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb
index baf652662..158ecc237 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -31,6 +31,7 @@ class Sanitize
next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes
next true if /^(mention|hashtag)$/.match?(e) # semantic classes
next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes
+ next true if /^quote-inline$/.match?(e) # quote inline classes
end
node['class'] = class_list.join(' ')