diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 38c9f5a20d0c7b..7deb7faf75ac38 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -7,8 +7,13 @@ 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
+ accountIds = @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(accountIds, 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 0cc23184098489..37703abba4baf3 100644
--- a/app/controllers/api/v1/bookmarks_controller.rb
+++ b/app/controllers/api/v1/bookmarks_controller.rb
@@ -6,8 +6,12 @@ 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
+ accountIds = @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(accountIds, 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 2a873696c0f2f2..42377c4618b3f3 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -6,8 +6,12 @@ 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
+ accountIds = @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(accountIds, 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 153bfc12f18ceb..a8a2a56538ebe3 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -31,8 +31,11 @@ def context
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
+ accountIds = 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(accountIds, current_user&.account_id)
end
def create
@@ -51,6 +54,7 @@ def create
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true,
local_only: status_params[:local_only]
+ quote_id: status_params[:quote_id].presence
)
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
@@ -112,6 +116,7 @@ def status_params
:language,
:scheduled_at,
:local_only,
+ :quote_id,
media_ids: [],
poll: [
:multiple,
diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb
index ae6dbcb8b378ee..c07e1a820532cc 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
+ accountIds = @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(accountIds, 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 a15eae468d92b4..bc13ed5ec9484d 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
+ accountIds = @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(accountIds, 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 d253b744f99bf9..d13da36c72447b 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -5,8 +5,13 @@ 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
+ accountIds = @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(accountIds, 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 64a1db58df3ae7..e6746ba6d1c45b 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
+ accountIds = @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(accountIds, current_user&.account_id)
end
private
diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb
index 08cfa9c6d57f79..0244bc5c30dc60 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' },
+ quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' },
}.freeze
def full_context
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
index a9d2f96512589a..6adb1393a94b12 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -15,6 +15,13 @@ def extract_status_plain_text(status)
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) : []),
+ quote_uri: status.quote? ? ActivityPub::TagManager.instance.url_for(status.quote) : nil,
+ )
+ end
+
+ def quote_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) : []))
end
diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js
index 544ed2ff224455..b3e0a2ab4b9925 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 58214e375c699b..3479ad0a20572f 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -21,6 +21,8 @@ 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_DIRECT = 'COMPOSE_DIRECT';
+export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
+export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
@@ -49,7 +51,6 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
-export const COMPOSE_FEDERATION_CHANGE = 'COMPOSE_FEDERATION_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
@@ -119,6 +120,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,
@@ -152,6 +170,7 @@ export function submitCompose(routerHistory) {
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null);
+ const quoteId = getState().getIn(['compose', 'quote_from'], null);
if ((!status || !status.length) && media.size === 0) {
return;
@@ -172,6 +191,7 @@ export function submitCompose(routerHistory) {
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
local_only: !getState().getIn(['compose', 'federation']),
+ 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 9448b1efe7eae9..9b28ac4c4647ba 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 f4372fb31d0783..35032fc708251f 100644
--- a/app/javascript/mastodon/actions/importer/index.js
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -70,6 +70,10 @@ export function importFetchedStatuses(statuses) {
processStatus(status.reblog);
}
+ if (status.quote && status.quote.id) {
+ processStatus(status.quote);
+ }
+
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll));
}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index ca76e3494d15a9..48be2a600c7209 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -12,7 +12,13 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
export function searchTextFromRawStatus (status) {
const spoilerText = status.spoiler_text || '';
- const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n');
+ const searchContent =
+ [spoilerText, status.content]
+ .concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])
+ .concat(status.media_attachments.map(att => att.description))
+ .join('\n\n')
+ .replace(/
/g, '\n')
+ .replace(/<\/p>
/g, '\n\n');
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
}
@@ -54,6 +60,10 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.poll = status.poll.id;
}
+ if (status.quote && status.quote.id) {
+ normalStatus.quote = status.quote.id;
+ }
+
// Only calculate these values when status first encountered and
// when the underlying values change. Otherwise keep the ones
// already in the reducer
@@ -72,10 +82,9 @@ export function normalizeStatus(status, normalOldStatus) {
}
const spoilerText = normalStatus.spoiler_text || '';
- const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
- normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+ normalStatus.search_index = searchTextFromRawStatus(status);
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 96cf628d693a47..fa500d7e06557a 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -45,7 +45,7 @@ defineMessages({
});
const fetchRelatedRelationships = (dispatch, notifications) => {
- const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
+ const accountIds = notifications.map(item => item.account.id);
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 44fedd5c2732d0..56e69e12a3d6d8 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(function(e){return e})));
dispatch({
type: TIMELINE_UPDATE,
@@ -57,14 +60,14 @@ export function deleteFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
- const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
+ const quotes = getState().get('statuses').filter(status => status.get('quote_id') === id).map(status => status.get('id'));
dispatch({
type: TIMELINE_DELETE,
id,
accountId,
references,
- reblogOf,
+ quotes,
});
};
};
@@ -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).concat(response.data.map(item => item.quote ? item.quote.account.id : null)).filter(function(e){return e}))));
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 2e7ce2e608f928..4499d961027361 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;
@@ -324,9 +326,9 @@ class MediaGallery extends React.PureComponent {
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
}
} else if (width) {
- style.height = width / (16/9);
+ style.height = width / (16/9) / (quote ? 2 : 1);
} else {
- style.height = height;
+ style.height = height / (quote ? 2 : 1);
}
const size = media.take(4).size;
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index 85aa28816ca2bc..c5ddcb1656dabe 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -49,6 +49,9 @@ class Poll extends ImmutablePureComponent {
static getDerivedStateFromProps (props, state) {
const { poll, intl } = props;
+ if (!poll) {
+ return null;
+ }
const expires_at = poll.get('expires_at');
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
return (expired === state.expired) ? null : { expired };
@@ -69,7 +72,7 @@ class Poll extends ImmutablePureComponent {
_setupTimer () {
const { poll, intl } = this.props;
clearTimeout(this._timer);
- if (!this.state.expired) {
+ if (!this.state.expired && !!poll) {
const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
this._timer = setTimeout(() => {
this.setState({ expired: true });
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 76609322667a81..0f6057da6e0010 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -1,4 +1,5 @@
import React from 'react';
+import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
@@ -23,6 +24,29 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
+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 {
+ quote_muted: status.get('quote_id', null) ? true : false,
+ };
+ }
+ const id = status.getIn(['quote', 'account', 'id'], null);
+
+ return {
+ quote_muted: 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', 'quote_muted']),
+ };
+};
+
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
@@ -60,7 +84,8 @@ const messages = defineMessages({
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});
-export default @injectIntl
+export default @connect(mapStateToProps)
+@injectIntl
class Status extends ImmutablePureComponent {
static contextTypes = {
@@ -71,6 +96,7 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
otherAccounts: ImmutablePropTypes.list,
+ quote_muted: PropTypes.bool,
onClick: PropTypes.func,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
@@ -102,6 +128,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
@@ -113,10 +140,12 @@ class Status extends ImmutablePureComponent {
'hidden',
'unread',
'pictureInPicture',
+ 'quote_muted',
];
state = {
showMedia: defaultMediaVisibility(this.props.status),
+ showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
statusId: undefined,
};
@@ -124,6 +153,7 @@ class Status extends ImmutablePureComponent {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
statusId: nextProps.status.get('id'),
};
} else {
@@ -135,7 +165,15 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
- handleClick = e => {
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
+ handleQuoteClick = e => {
+ this.handleClick(e, true);
+ }
+
+ handleClick = (e, quote = false) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
@@ -144,14 +182,18 @@ class Status extends ImmutablePureComponent {
e.preventDefault();
}
- this.handleHotkeyOpen();
+ this.handleHotkeyOpen(quote);
}
handlePrependAccountClick = e => {
this.handleAccountClick(e, false);
}
- handleAccountClick = (e, proper = true) => {
+ handleQuoteAccountClick = e => {
+ this.handleAccountClick(e, true, true);
+ }
+
+ handleAccountClick = (e, proper = true, quote = false) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
@@ -160,7 +202,7 @@ class Status extends ImmutablePureComponent {
e.preventDefault();
}
- this._openProfile(proper);
+ this._openProfile(proper, quote);
}
handleExpandedToggle = () => {
@@ -171,6 +213,10 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}
+ handleExpandedQuoteToggle = () => {
+ this.props.onToggleHidden(this._properQuoteStatus());
+ };
+
renderLoadingMediaGallery () {
return
;
}
@@ -188,10 +234,19 @@ class Status extends ImmutablePureComponent {
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
}
+ handleOpenVideoQuote = (options) => {
+ const status = this._properQuoteStatus();
+ this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
+ }
+
handleOpenMedia = (media, index) => {
this.props.onOpenMedia(this._properStatus().get('id'), media, index);
}
+ handleOpenMediaQuote = (media, index) => {
+ this.props.onOpenMedia(this._properQuoteStatus().get('id'), media, index);
+ }
+
handleHotkeyOpenMedia = e => {
const { onOpenMedia, onOpenVideo } = this.props;
const status = this._properStatus();
@@ -232,14 +287,14 @@ class Status extends ImmutablePureComponent {
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
}
- handleHotkeyOpen = () => {
+ handleHotkeyOpen = (quote = false) => {
if (this.props.onClick) {
this.props.onClick();
return;
}
const { router } = this.context;
- const status = this._properStatus();
+ const status = quote ? this._properQuoteStatus() : this._properStatus();
if (!router) {
return;
@@ -252,9 +307,10 @@ class Status extends ImmutablePureComponent {
this._openProfile();
}
- _openProfile = (proper = true) => {
+ _openProfile = (proper = true, quote = false) => {
const { router } = this.context;
- const status = proper ? this._properStatus() : this.props.status;
+ const properStatus = proper ? this._properStatus() : this.props.status;
+ const status = quote ? properStatus.get('quote') : properStatus;
if (!router) {
return;
@@ -289,6 +345,16 @@ class Status extends ImmutablePureComponent {
}
}
+ _properQuoteStatus () {
+ const status = this._properStatus();
+
+ if (status.get('quote', null) !== null && typeof status.get('quote') === 'object') {
+ return status.get('quote');
+ } else {
+ return status;
+ }
+ }
+
handleRef = c => {
this.node = c;
}
@@ -297,7 +363,7 @@ class Status extends ImmutablePureComponent {
let media = null;
let statusAvatar, prepend, rebloggedByText;
- const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props;
+ const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType, quote_muted } = this.props;
let { status, account, ...other } = this.props;
@@ -368,10 +434,10 @@ class Status extends ImmutablePureComponent {
status = status.get('reblog');
}
- if (pictureInPicture.get('inUse')) {
- media = ;
- } else if (status.get('media_attachments').size > 0) {
- if (this.props.muted) {
+ if (status.get('media_attachments').size > 0) {
+ if (pictureInPicture.get('inUse')) {
+ media = ;
+ } else if (this.props.muted) {
media = (
0) {
+ if (pictureInPicture.get('inUse')) {
+ quote_media = ;
+ } else if (this.props.muted) {
+ quote_media = (
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else {
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ }
+ }
+
+ if (quote_muted) {
+ quote = (
+
+ );
+ } else if (quote_status.get('visibility') === 'unlisted' && !!contextType && ['public', 'community', 'hashtag'].includes(contextType.split(':', 2)[0])) {
+ quote = (
+
+ );
+ } else {
+ quote = (
+
+
+
+
+
+ {quote_media}
+
+ );
+ }
+ } else if (quote_muted) {
+ quote = (
+
+ );
+ }
+
return (
@@ -498,6 +693,7 @@ class Status extends ImmutablePureComponent {
+ {quote}
{media}
@@ -507,4 +703,4 @@ class Status extends ImmutablePureComponent {
);
}
-}
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 51429b441775b8..ef037525152552 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -24,8 +24,10 @@ const messages = defineMessages({
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
@@ -63,6 +65,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,
@@ -132,6 +135,10 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onBookmark(this.props.status);
}
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status, this.context.router.history);
+ }
+
handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}
@@ -336,6 +343,7 @@ class StatusActionBar extends ImmutablePureComponent {
+
{shareButton}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index d01365afbfe659..55a7219874ad31 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -24,6 +24,7 @@ export default class StatusContent extends React.PureComponent {
onClick: PropTypes.func,
collapsable: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
+ quote: PropTypes.bool,
};
state = {
@@ -168,11 +169,12 @@ export default class StatusContent extends React.PureComponent {
}
render () {
- const { status } = this.props;
+ const { status, quote } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');
const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
+ const renderShowPoll = !!status.get('poll');
const content = { __html: status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') };
@@ -194,6 +196,16 @@ export default class StatusContent extends React.PureComponent {
);
+ const showPollButton = (
+
+ );
+
+ const pollContainer = (
+
+ );
+
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
@@ -221,7 +233,7 @@ export default class StatusContent extends React.PureComponent {
- {!hidden && !!status.get('poll') &&
}
+ {!hidden && renderShowPoll && quote ? showPollButton : pollContainer}
{renderViewThread && showThreadButton}
@@ -231,7 +243,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') &&
}
+ {renderShowPoll && quote ? showPollButton : pollContainer}
{renderViewThread && showThreadButton}
,
@@ -247,7 +259,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') &&
}
+ {renderShowPoll && quote ? showPollButton : pollContainer}
{renderViewThread && showThreadButton}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index ef0aca13a6d947..9e69b47dd3bf34 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';
@@ -51,6 +52,8 @@ const messages = defineMessages({
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
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' },
});
@@ -100,6 +103,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
+ 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));
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
index ed8095f90e23a5..768169458ed9b7 100644
--- a/app/javascript/mastodon/containers/timeline_container.js
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -47,7 +47,9 @@ export default class TimelineContainer extends React.PureComponent {
- {timeline}
+
+ {timeline}
+
{ReactDOM.createPortal(
,
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index c47f55dd1ea8a4..c65a0492900930 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -42,6 +42,7 @@ class Audio extends React.PureComponent {
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
+ quote: PropTypes.bool,
};
state = {
@@ -83,7 +84,7 @@ class Audio extends React.PureComponent {
_setDimensions () {
const width = this.player.offsetWidth;
- const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
+ const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9) / (this.props.quote ? 2 : 1));
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 43362c538139a9..b6bc0e5af2e784 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -4,6 +4,7 @@ import Button from '../../../components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import QuoteIndicatorContainer from '../containers/quote_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestInput from '../../../components/autosuggest_input';
import PollButtonContainer from '../containers/poll_button_container';
@@ -223,6 +224,7 @@ class ComposeForm extends ImmutablePureComponent {
+
{
+ this.props.onCancel();
+ }
+
+ handleAccountClick = (e) => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
+ }
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ const content = { __html: status.get('contentHtml') };
+
+ return (
+
+
+
+
+
+ {status.get('media_attachments').size > 0 && (
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
new file mode 100644
index 00000000000000..52c57caacdaf69
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import { cancelQuoteCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import QuoteIndicator from '../components/quote_indicator';
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = state => {
+ let statusId = state.getIn(['compose', 'id'], null);
+ let editing = true;
+
+ if (statusId === null) {
+ statusId = state.getIn(['compose', 'quote_from']);
+ editing = false;
+ }
+
+ return {
+ status: getStatus(state, { id: statusId }),
+ editing,
+ };
+ };
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+ onCancel () {
+ dispatch(cancelQuoteCompose());
+ },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator);
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 0cb42b25aac652..b01f0d97dcc06b 100644
--- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -7,7 +7,7 @@ import IconButton from 'mastodon/components/icon_button';
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 +20,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' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
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' },
});
@@ -123,6 +127,31 @@ class Footer extends ImmutablePureComponent {
router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
}
+ _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();
+ }
+ }
+
render () {
const { status, intl, withOpenButton } = this.props;
@@ -156,6 +185,7 @@ class Footer extends ImmutablePureComponent {
+
{withOpenButton && }
);
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index edaff959e6cd5d..8c9c9001fd0866 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -18,7 +18,9 @@ const messages = defineMessages({
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
@@ -57,6 +59,7 @@ class ActionBar extends React.PureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
+ onQuote: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
@@ -84,6 +87,10 @@ class ActionBar extends React.PureComponent {
this.props.onReblog(this.props.status, e);
}
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status);
+ }
+
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
@@ -284,6 +291,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 3d81bcb29b626d..52556d4df5009d 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -60,6 +60,10 @@ const addAutoPlay = html => {
export default class Card extends React.PureComponent {
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
static propTypes = {
card: ImmutablePropTypes.map,
maxDescription: PropTypes.number,
@@ -68,6 +72,7 @@ export default class Card extends React.PureComponent {
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
+ quote: PropTypes.bool,
};
static defaultProps = {
@@ -184,7 +189,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 +202,7 @@ 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);
+ const height = ((compact && !embedded) ? (width / (16 / 9)) : (width / ratio)) / (quote ? 2 : 1);
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 2cfe13f79b442a..1bc5f8aaa9aa07 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -1,4 +1,5 @@
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';
@@ -6,7 +7,7 @@ import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
+import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
@@ -26,7 +27,31 @@ const messages = defineMessages({
local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' },
});
-export default @injectIntl
+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 {
+ quote_muted: status.get('quote_id', null) ? true : false,
+ };
+ }
+ const id = status.getIn(['quote', 'account', 'id'], null);
+
+ return {
+ quote_muted: 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', 'quote_muted']),
+ };
+};
+
+export default @connect(mapStateToProps)
+@injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
@@ -35,8 +60,11 @@ class DetailedStatus extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
+ quote_muted: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
+ onOpenMediaQuote: PropTypes.func.isRequired,
+ onOpenVideoQuote: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
@@ -48,6 +76,8 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
+ showQuoteMedia: PropTypes.bool,
+ onToggleQuoteMediaVisibility: PropTypes.func,
};
state = {
@@ -63,10 +93,23 @@ class DetailedStatus extends ImmutablePureComponent {
e.stopPropagation();
}
+ handleQuoteAccountClick = (e) => {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
+ e.preventDefault();
+ this.context.router.history.push(`/@${this.props.status.getIn(['quote', 'account', 'acct'])}`);
+ }
+
+ e.stopPropagation();
+ }
+
handleOpenVideo = (options) => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
}
+ handleOpenVideoQuote = (options) => {
+ this.props.onOpenVideoQuote(this.props.status.getIn(['quote', 'media_attachments', 0]), options);
+ }
+
handleExpandedToggle = () => {
this.props.onToggleHidden(this.props.status);
}
@@ -104,8 +147,22 @@ class DetailedStatus extends ImmutablePureComponent {
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
+ handleExpandedQuoteToggle = () => {
+ this.props.onToggleHidden(this.props.status.get('quote'));
+ }
+
+ handleQuoteClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/@${status.getIn(['quote', 'account', 'acct'])}/${status.getIn(['quote', 'id'])}`);
+ }
+
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
+ const quote_muted = this.props.quote_muted
const outerStyle = { boxSizing: 'border-box' };
const { intl, compact, pictureInPicture } = this.props;
@@ -125,6 +182,97 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
+ let quote = null;
+ if (status.get('quote', null) !== null) {
+ let quote_status = status.get('quote');
+
+ let quote_media = null;
+ if (quote_status.get('media_attachments').size > 0) {
+
+ if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ );
+ } else {
+ quote_media = (
+
+ );
+ }
+ }
+
+ if (quote_muted) {
+ quote = (
+
+ );
+ } else {
+ quote = (
+
+
+
+
+
+
+
+
+ {quote_media}
+
+ );
+ }
+ } else if (quote_muted) {
+ quote = (
+
+ );
+ }
+
if (pictureInPicture.get('inUse')) {
media =
;
} else if (status.get('media_attachments').size > 0) {
@@ -264,6 +412,7 @@ class DetailedStatus extends ImmutablePureComponent {
+ {quote}
{media}
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 bfed1662007f15..d307e05bccf5c9 100644
--- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -136,6 +136,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal('VIDEO', { media, options }));
},
+ onOpenMediaQuote (media, index) {
+ dispatch(openModal('MEDIA', { media, index }));
+ },
+
+ onOpenVideoQuote (media, options) {
+ dispatch(openModal('VIDEO', { media, options }));
+ },
+
onBlock (status) {
const account = status.get('account');
dispatch(initBlockModal(account));
@@ -164,7 +172,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(hideStatus(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 4d7f24834e42d9..ce86e0c7670762 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -22,6 +22,7 @@ import {
} from '../../actions/interactions';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@@ -69,6 +70,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' },
});
@@ -181,6 +184,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,
};
@@ -199,7 +203,8 @@ 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), loadedStatusId: nextProps.status.get('id'),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)) });
}
}
@@ -207,6 +212,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
handleFavouriteClick = (status) => {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
@@ -260,6 +269,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));
+ }
+ }
+
handleDeleteClick = (status, history, withRedraft = false) => {
const { dispatch, intl } = this.props;
@@ -294,6 +316,14 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
}
+ handleOpenMediaQuote = (media, index) => {
+ this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.getIn(['quote', 'id']), media, index }));
+ }
+
+ handleOpenVideoQuote = (media, options) => {
+ this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.getIn(['quote', 'id']), media, options }));
+ }
+
handleHotkeyOpenMedia = e => {
const { status } = this.props;
@@ -557,11 +587,15 @@ class Status extends ImmutablePureComponent {
status={status}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
+ onOpenVideoQuote={this.handleOpenVideoQuote}
+ onOpenMediaQuote={this.handleOpenMediaQuote}
onToggleHidden={this.handleToggleHidden}
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
pictureInPicture={pictureInPicture}
+ showQuoteMedia={this.state.showQuoteMedia}
+ onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
/>
{
});
};
+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;
+};
+
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
@@ -329,6 +344,7 @@ export default function compose(state = initialState, action) {
return state.withMutations(map => {
map.set('id', null);
map.set('in_reply_to', action.status.get('id'));
+ map.set('quote_from', null);
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('federation', !action.status.get('local_only'));
@@ -345,6 +361,40 @@ export default function compose(state = initialState, action) {
map.set('spoiler_text', '');
}
});
+ case COMPOSE_QUOTE:
+ return state.withMutations(map => {
+ map.set('id', null);
+ map.set('in_reply_to', null);
+ map.set('quote_from', action.status.get('id'));
+ map.set('text', '');
+ map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+ map.set('focusDate', new Date());
+ map.set('caretPosition', null);
+ map.set('preselectDate', new Date());
+ map.set('idempotencyKey', uuid());
+
+ if (action.status.get('spoiler_text').length > 0) {
+ map.set('spoiler', true);
+ map.set('spoiler_text', action.status.get('spoiler_text'));
+ } else {
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ }
+ });
+ case COMPOSE_REPLY_CANCEL:
+ case COMPOSE_QUOTE_CANCEL:
+ case COMPOSE_RESET:
+ return state.withMutations(map => {
+ map.set('id', null);
+ map.set('in_reply_to', null);
+ map.set('quote_from', null);
+ map.set('text', '');
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ map.set('privacy', state.get('default_privacy'));
+ map.set('poll', null);
+ map.set('idempotencyKey', uuid());
+ });
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
case COMPOSE_UPLOAD_CHANGE_REQUEST:
@@ -444,10 +494,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.get('quote'));
map.set('privacy', action.status.get('visibility'));
- map.set('federation', !action.status.get('local_only'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date());
map.set('caretPosition', null);
@@ -472,6 +522,36 @@ export default function compose(state = initialState, action) {
}
});
case COMPOSE_SET_STATUS:
+ return state.withMutations(map => {
+ map.set('id', action.status.get('id'));
+ map.set('text', action.text);
+ map.set('in_reply_to', action.status.get('in_reply_to_id'));
+ map.set('privacy', action.status.get('visibility'));
+ map.set('federation', !action.status.get('local_only'));
+ map.set('media_attachments', action.status.get('media_attachments'));
+ map.set('focusDate', new Date());
+ map.set('caretPosition', null);
+ map.set('idempotencyKey', uuid());
+ map.set('sensitive', action.status.get('sensitive'));
+ map.set('language', action.status.get('language'));
+
+ if (action.spoiler_text.length > 0) {
+ map.set('spoiler', true);
+ map.set('spoiler_text', action.spoiler_text);
+ } else {
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ }
+
+ if (action.status.get('poll')) {
+ map.set('poll', ImmutableMap({
+ options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
+ multiple: action.status.getIn(['poll', 'multiple']),
+ expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
+ }));
+ }
+ });
+ case COMPOSE_SET_STATUS:
return state.withMutations(map => {
map.set('id', action.status.get('id'));
map.set('text', action.text);
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 7dceac6b93f4e3..e0a4d3df7895ad 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -11,6 +11,7 @@ import {
COMPOSE_MENTION,
COMPOSE_REPLY,
COMPOSE_DIRECT,
+ COMPOSE_QUOTE,
} from '../actions/compose';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
@@ -39,6 +40,7 @@ export default function search(state = initialState, action) {
case COMPOSE_REPLY:
case COMPOSE_MENTION:
case COMPOSE_DIRECT:
+ case COMPOSE_QUOTE:
return state.set('hidden', true);
case SEARCH_FETCH_REQUEST:
return state.withMutations(map => {
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 53dec95859d118..5eff2c31a6b4f6 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -23,9 +23,13 @@ const importStatus = (state, status) => state.set(status.id, fromJS(status));
const importStatuses = (state, statuses) =>
state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status)));
-const deleteStatus = (state, id, references) => {
+const deleteStatus = (state, id, references, quotes) => {
references.forEach(ref => {
- state = deleteStatus(state, ref, []);
+ state = deleteStatus(state, ref, [], []);
+ });
+
+ quotes.forEach(quote => {
+ state = state.setIn([quote, 'quote_id'], null).setIn([quote, 'quote'], null)
});
return state.delete(id);
@@ -76,7 +80,7 @@ export default function statuses(state = initialState, action) {
case STATUS_COLLAPSE:
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
case TIMELINE_DELETE:
- return deleteStatus(state, action.id, action.references);
+ return deleteStatus(state, action.id, action.references, action.quotes);
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index d72109e696a805..fd87cb3ee9daa7 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -196,7 +196,7 @@ export default function timelines(state = initialState, action) {
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems);
case TIMELINE_DELETE:
- return deleteStatus(state, action.id, action.references, action.reblogOf);
+ return deleteStatus(state, action.id, action.references);
case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline);
case ACCOUNT_BLOCK_SUCCESS:
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 3121774b3d6b5d..777cf58458d6db 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -88,22 +88,60 @@ 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(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote', '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'])]),
+ (state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote', 'account']), 'moved'])]),
getFiltersRegex,
],
- (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
+ (statusBase, statusReblog, statusQuote, accountBase, accountReblog, accountQuote, accountReblogQuote, relationship, reblogRelationship, quoteRelationship, reblogQuoteRelationship, moved, reblogMoved, quoteMoved, reblogQuoteMoved, filtersRegex) => {
if (!statusBase) {
return null;
}
+ accountBase = accountBase.withMutations(map => {
+ map.set('relationship', relationship);
+ map.set('moved', moved);
+ });
+
if (statusReblog) {
+ accountReblog = accountReblog.withMutations(map => {
+ map.set('relationship', reblogRelationship);
+ map.set('moved', reblogMoved);
+ });
statusReblog = statusReblog.set('account', accountReblog);
} else {
statusReblog = null;
}
+ if (statusQuote) {
+ accountQuote = accountQuote.withMutations(map => {
+ map.set('relationship', quoteRelationship);
+ map.set('moved', quoteMoved);
+ });
+ statusQuote = statusQuote.set('account', accountQuote);
+ } else {
+ statusQuote = null;
+ }
+
+ if (statusReblog && accountReblogQuote) {
+ accountReblogQuote = accountReblog.withMutations(map => {
+ map.set('relationship', reblogQuoteRelationship);
+ map.set('moved', reblogQuoteMoved);
+ });
+ statusReblog = statusReblog.setIn(['quote', 'account'], accountReblogQuote);
+ }
+
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
return null;
@@ -114,6 +152,7 @@ export const makeGetStatus = () => {
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
+ map.set('quote', statusQuote);
map.set('account', accountBase);
map.set('filtered', filtered);
});
diff --git a/app/javascript/mastodon/utils/uniq.js b/app/javascript/mastodon/utils/uniq.js
new file mode 100644
index 00000000000000..00f1804a19e07d
--- /dev/null
+++ b/app/javascript/mastodon/utils/uniq.js
@@ -0,0 +1,3 @@
+export const uniq = array => {
+ return array.filter((x, i, self) => self.indexOf(x) === i)
+};
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3d0a937e1f8074..6e5da7d03797a4 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -289,6 +289,31 @@ 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('.detailed-status__display-name')) {
+ url = target.closest('.detailed-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', 'noopener,noreferrer');
+ }
+ return false;
+ });
}
loadPolyfills()
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index b066d3abdec7de..e291163307cc5d 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -719,26 +719,37 @@
}
.reply-indicator {
+ background: $ui-primary-color;
+}
+
+.quote-indicator {
+ background: $success-green;
+}
+
+.reply-indicator,
+.quote-indicator {
border-radius: 4px;
margin-bottom: 10px;
- background: $ui-primary-color;
padding: 10px;
min-height: 23px;
overflow-y: auto;
flex: 0 2 auto;
}
-.reply-indicator__header {
+.reply-indicator__header,
+.quote-indicator__header {
margin-bottom: 5px;
overflow: hidden;
}
-.reply-indicator__cancel {
+.reply-indicator__cancel,
+.quote-indicator__cancel {
float: right;
line-height: 24px;
}
-.reply-indicator__display-name {
+.reply-indicator__display-name,
+.quote-indicator__display-name {
color: $inverted-text-color;
display: block;
max-width: 100%;
@@ -748,7 +759,8 @@
text-decoration: none;
}
-.reply-indicator__display-avatar {
+.reply-indicator__display-avatar,
+.quote-indicator__display-avatar {
float: left;
margin-right: 5px;
}
@@ -762,7 +774,8 @@
}
.status__content,
-.reply-indicator__content {
+.reply-indicator__content,
+.quote-indicator__content {
position: relative;
font-size: 15px;
line-height: 20px;
@@ -966,6 +979,76 @@
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
+.quote-inline {
+ display: none;
+}
+
+.quote-status {
+ border: solid 1px $ui-base-lighter-color;
+ border-radius: 4px;
+ padding: 5px;
+ margin-top: 8px;
+ position: relative;
+
+ & > .unlisted-quote {
+ color: $dark-text-color;
+ font-weight: 500;
+
+ & > button {
+ color: $dark-text-color;
+ font-size: 100%;
+ background-color: transparent;
+ border: 0;
+ cursor: pointer;
+ outline: none;
+ padding: 0;
+ appearance: none;
+ }
+ }
+
+ & > .muted-quote {
+ color: $dark-text-color;
+ font-weight: 500;
+ font-size: 100%;
+ }
+
+ .status__avatar,
+ .detailed-status__display-avatar {
+ height: 18px;
+ width: 18px;
+ position: absolute;
+ top: 5px !important;
+ left: 5px !important;
+ cursor: pointer;
+
+ & > div {
+ width: 18px;
+ height: 18px;
+ }
+ }
+
+ .display-name__account {
+ color: $ui-base-lighter-color;
+ }
+
+ .display-name {
+ padding-left: 20px;
+ }
+
+ .detailed-status__display-name {
+ margin-bottom: 0px;
+
+ strong,
+ span {
+ display: inline;
+ }
+ }
+}
+
+.muted .quote-status .display-name {
+ color: $ui-base-lighter-color;
+}
+
.status__prepend-icon-wrapper {
left: -26px;
position: absolute;
@@ -1222,7 +1305,8 @@
margin-left: 6px;
}
-.reply-indicator__content {
+.reply-indicator__content,
+.quote-indicator__content {
color: $inverted-text-color;
font-size: 14px;
@@ -1421,77 +1505,6 @@ a .account__avatar {
}
}
-.account__action-bar {
- border-top: 1px solid lighten($ui-base-color, 8%);
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- line-height: 36px;
- overflow: hidden;
- flex: 0 0 auto;
- display: flex;
-}
-
-.account__action-bar-dropdown {
- padding: 10px;
-
- .icon-button {
- vertical-align: middle;
- }
-
- .dropdown--active {
- .dropdown__content.dropdown__right {
- left: 6px;
- right: initial;
- }
-
- &::after {
- bottom: initial;
- margin-left: 11px;
- margin-top: -7px;
- right: initial;
- }
- }
-}
-
-.account__action-bar-links {
- display: flex;
- flex: 1 1 auto;
- line-height: 18px;
- text-align: center;
-}
-
-.account__action-bar__tab {
- text-decoration: none;
- overflow: hidden;
- flex: 0 1 100%;
- border-right: 1px solid lighten($ui-base-color, 8%);
- padding: 10px 0;
- border-bottom: 4px solid transparent;
-
- &.active {
- border-bottom: 4px solid $ui-highlight-color;
- }
-
- & > span {
- display: block;
- text-transform: uppercase;
- font-size: 11px;
- color: $darker-text-color;
- }
-
- strong {
- display: block;
- font-size: 15px;
- font-weight: 500;
- color: $primary-text-color;
-
- @each $lang in $cjk-langs {
- &:lang(#{$lang}) {
- font-weight: 700;
- }
- }
- }
-}
-
.account-authorize {
padding: 14px 10px;
@@ -1578,6 +1591,15 @@ a.account__display-name {
margin-right: 10px;
}
+.account__action-bar {
+ position: absolute;
+ height: 24px;
+ width: 48px;
+ top: 60px;
+ left: 10px;
+ z-index: 1;
+}
+
.status__avatar {
height: 48px;
left: 10px;
@@ -2423,6 +2445,11 @@ a.account__display-name {
margin-right: 15px;
}
}
+
+ .account__action-bar {
+ top: 67px;
+ left: 15px;
+ }
}
}
@@ -2564,7 +2591,7 @@ a.account__display-name {
.column-actions {
display: flex;
- align-items: start;
+ align-items: flex-start;
justify-content: center;
padding: 40px;
padding-top: 40px;
diff --git a/app/javascript/styles/mastodon/statuses.scss b/app/javascript/styles/mastodon/statuses.scss
index 078714325eef30..47dde12fdefd1b 100644
--- a/app/javascript/styles/mastodon/statuses.scss
+++ b/app/javascript/styles/mastodon/statuses.scss
@@ -63,6 +63,28 @@
}
}
+ .status.quote-status {
+ border: solid 1px $ui-base-lighter-color;
+ border-radius: 4px;
+ padding: 5px;
+ margin-top: 15px;
+ cursor: pointer;
+ width: 100%;
+
+ .status__avatar {
+ height: 18px;
+ width: 18px;
+ position: absolute;
+ top: 5px;
+ left: 5px;
+
+ & img {
+ width: 18px;
+ height: 18px;
+ }
+ }
+ }
+
@media screen and (max-width: 740px) {
.detailed-status,
.status,
@@ -77,6 +99,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 73882e1348f2ab..63d5c0af9f81b9 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -76,6 +76,7 @@ def process_status
@silenced_account_ids = []
@params = {}
+ process_quote
process_status_params
process_tags
process_audience
@@ -126,6 +127,7 @@ def process_status_params
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,24 @@ def increment_voters_count!
poll.reload
retry
end
+
+ def quote
+ @quote ||= quote_from_url(@object['quoteUri'] || @object['_misskey_quote'])
+ end
+
+ def process_quote
+ if quote.nil? && md = @object['content']&.match(/QT:\s*\[
\1')
+ 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 7f716f86243ef8..506264eb866ebf 100644
--- a/app/lib/activitypub/case_transform.rb
+++ b/app/lib/activitypub/case_transform.rb
@@ -6,6 +6,14 @@ def camel_lower_cache
@camel_lower_cache ||= {}
end
+ NON_CONVERSIONS = %w(
+ _misskey_content
+ _misskey_quote
+ _misskey_reaction
+ _misskey_votes
+ _misskey_talk
+ ).freeze
+
def camel_lower(value)
case value
when Array then value.map { |item| camel_lower(item) }
@@ -14,6 +22,8 @@ def camel_lower(value)
when String
camel_lower_cache[value] ||= if value.start_with?('_:')
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
+ elsif NON_CONVERSIONS.include? value
+ value
else
value.underscore.camelize(:lower)
end
diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb
index 3ba154d01551fa..028d0950c288b3 100644
--- a/app/lib/activitypub/parser/status_parser.rb
+++ b/app/lib/activitypub/parser/status_parser.rb
@@ -2,6 +2,7 @@
class ActivityPub::Parser::StatusParser
include JsonLdHelper
+ include FormattingHelper
# @param [Hash] json
# @param [Hash] magic_values
@@ -29,7 +30,9 @@ def url
end
def text
- if @object['content'].present?
+ if @object['quoteUri'].blank? && @object['_misskey_quote'].present?
+ linkify(@object['_misskey_content'])
+ elsif @object['content'].present?
@object['content']
elsif content_language_map?
@object['contentMap'].values.first
diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb
index 48e2fc2338dcc1..c596700747384c 100644
--- a/app/lib/text_formatter.rb
+++ b/app/lib/text_formatter.rb
@@ -21,6 +21,7 @@ class TextFormatter
# @option options [Boolean] :with_domains
# @option options [Boolean] :with_rel_me
# @option options [Array] :preloaded_accounts
+ # @option options [String] :quote_uri
def initialize(text, options = {})
@text = text
@options = DEFAULT_OPTIONS.merge(options)
@@ -44,12 +45,18 @@ def to_s
end
html = simple_format(html, {}, sanitize: false).delete("\n") if multiline?
+ html = quotify(html, quote_uri) if quote_uri?
html.html_safe # rubocop:disable Rails/OutputSafety
end
private
+ def quotify(html, quote_uri)
+ link = link_to_url(url: quote_uri)
+ html.sub(/(<[^>]+>)\z/, "
QT: #{link}\\1")
+ end
+
def rewrite
entities.sort_by! do |entity|
entity[:indices].first
@@ -152,6 +159,12 @@ def preloaded_accounts
options[:preloaded_accounts]
end
+ def quote_uri
+ options[:quote_uri]
+ end
+
+ alias quote_uri? quote_uri
+
def preloaded_accounts?
preloaded_accounts.present?
end
diff --git a/app/models/status.rb b/app/models/status.rb
index 1bf41b38e47969..7e7309befe5ac5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -27,6 +27,7 @@
# trendable :boolean
# ordered_media_attachment_ids :bigint(8) is an Array
# local_only :boolean
+# quote_id :bigint(8)
#
class Status < ApplicationRecord
@@ -60,6 +61,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 :mentions, dependent: :destroy, inverse_of: :status
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
+ has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@@ -83,6 +86,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
@@ -188,6 +192,14 @@ def reblog?
!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
@@ -252,7 +264,16 @@ def emojis
fields = [spoiler_text, text]
fields += preloadable_poll.options unless preloadable_poll.nil?
- @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
+ @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : [])
+ end
+
+ def ordered_media_attachments
+ if ordered_media_attachment_ids.nil?
+ media_attachments
+ else
+ map = media_attachments.index_by(&:id)
+ ordered_media_attachment_ids.filter_map { |media_attachment_id| map[media_attachment_id] }
+ end
end
def ordered_media_attachments
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 27e058199d6fac..d42003bae1fc06 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,7 +3,7 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer
include FormattingHelper
- context_extensions :atom_uri, :conversation, :sensitive, :voters_count
+ context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quote_uri
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
@@ -11,6 +11,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:atom_uri, :in_reply_to_atom_uri,
:conversation
+ attribute :quote_uri, 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 @@ def conversation
end
end
+ def quote_uri
+ ActivityPub::TagManager.instance.uri_for(object.quote) if object.quote?
+ end
+
+ alias misskey_quote quote_uri
+
+ 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 0dc44b6239ccf0..30269593cd9b7d 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -6,7 +6,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :uri, :title, :short_description, :description, :email,
:version, :urls, :stats, :thumbnail,
:languages, :registrations, :approval_required, :invites_enabled,
- :configuration
+ :configuration,
+ :feature_quote
has_one :contact_account, serializer: REST::AccountSerializer
@@ -96,6 +97,10 @@ def invites_enabled
Setting.min_invite_role == 'user'
end
+ def feature_quote
+ true
+ end
+
private
def instance_presenter
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 277f307076e163..c5a2236db2fbec 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -17,6 +17,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
@@ -41,6 +43,10 @@ def in_reply_to_account_id
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
@@ -173,3 +179,23 @@ def url
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] ? true : false || 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] || false
+ 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 e5b5b730eca2e5..f9c336ee389c25 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -74,7 +74,7 @@ def parse_urls
@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
@@ -85,7 +85,7 @@ def parse_urls
def bad_url?(uri)
# Avoid local instance URLs and invalid URLs
- uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
+ uri.host.blank? || (TagManager.instance.local_url?(uri.to_s) && uri.to_s !~ %r(/users/[\w_-]+/statuses/\w+)) || !%w(http https).include?(uri.scheme)
end
def mention_link?(anchor)
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 9adb929ac92745..86dbc1649ebf95 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -27,11 +27,13 @@ def call(account, options = {})
@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!
@@ -48,6 +50,19 @@ def call(account, options = {})
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?
@@ -55,10 +70,21 @@ def preprocess_attributes!
@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? && md = @text.match(/QT:\s*\[\s*(https:\/\/.+?)\s*\]/)
+ @quote_id = quote_from_url(md[1])&.id
+ @text.sub!(/QT:\s*\[.*?\]/, '')
+ 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
@@ -178,6 +204,7 @@ def status_attributes
application: @options[:application],
rate_limit: @options[:with_rate_limit],
local_only: local_only_option(@options[:local_only], @in_reply_to, @account.user&.setting_default_federation),
+ quote_id: @quote_id,
}.compact
end
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index ecc28f065fd8b0..ab9a14442b66a7 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -26,6 +26,9 @@
- if status.preloadable_poll
= render_poll_component(status)
+ - if status.quote?
+ = render partial: "quote_status", locals: {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)
diff --git a/app/views/statuses/_quote_status.html.haml b/app/views/statuses/_quote_status.html.haml
new file mode 100644
index 00000000000000..671a83f7921c17
--- /dev/null
+++ b/app/views/statuses/_quote_status.html.haml
@@ -0,0 +1,34 @@
+.status.quote-status{ class: "quote-status-#{status.visibility}", dataurl: ActivityPub::TagManager.instance.url_for(status) }
+ .status__info
+ .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, width: 18, height: 18, alt: '', class: 'u-photo account__avatar'
+ - else
+ = image_tag status.account.avatar_static_url, width: 18, height: 18, 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, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}" }
+ = prerender_custom_emojis(quote_status_content_format(status), status.emojis)
+
+ - if !status.ordered_media_attachments.empty?
+ - if status.ordered_media_attachments.first.video?
+ = render_video_component(status, width: 610, height: 343, quote: true)
+ - elsif status.ordered_media_attachments.first.audio?
+ = render_audio_component(status, width: 610, height: 343, quote: true)
+ - else
+ = render_media_gallery_component(status, height: 343, quote: true)
+ - elsif status.preview_card
+ = render_card_component(status, quote: true)
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 76a35299c3f9e7..1030979d9e854e 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -38,6 +38,9 @@
- if status.preloadable_poll
= render_poll_component(status)
+ - if status.quote?
+ = render partial: "statuses/quote_status", locals: {status: status.quote}
+
- if !status.ordered_media_attachments.empty?
- if status.ordered_media_attachments.first.video?
= render_video_component(status, width: 610, height: 343)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index bc60a69a77a9e1..db76860e9e9c66 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1709,7 +1709,6 @@ en:
otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
signed_in_as: 'Signed in as:'
- suspicious_sign_in_confirmation: You appear to not have logged in from this device before, so we're sending a security code to your e-mail address to confirm that it's you.
verification:
explanation_html: 'You can verify yourself as the owner of the links in your profile metadata. For that, the linked website must contain a link back to your Mastodon profile. The link back must have a rel="me"
attribute. The text content of the link does not matter. Here is an example:'
verification: Verification
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 00000000000000..d7550b24887964
--- /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 00000000000000..45293db98cb167
--- /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 0a08d0321ad08c..6c5da0e5e76434 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -893,10 +893,11 @@
t.bigint "in_reply_to_account_id"
t.bigint "poll_id"
t.datetime "deleted_at"
- t.boolean "local_only"
t.datetime "edited_at"
t.boolean "trendable"
t.bigint "ordered_media_attachment_ids", array: true
+ t.boolean "local_only"
+ t.bigint "quote_id"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["account_id"], name: "index_statuses_on_account_id"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
@@ -904,6 +905,7 @@
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 a2e1d9d01b42b5..de6b132ee960ab 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -31,6 +31,7 @@ module Config
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(' ')
diff --git a/spec/controllers/activitypub/outboxes_controller_spec.rb b/spec/controllers/activitypub/outboxes_controller_spec.rb
index 24ce26217d1e90..04f03644729633 100644
--- a/spec/controllers/activitypub/outboxes_controller_spec.rb
+++ b/spec/controllers/activitypub/outboxes_controller_spec.rb
@@ -59,10 +59,6 @@
expect(response.headers['Vary']).to be_nil
end
- it 'does not have a Vary header' do
- expect(response.headers['Vary']).to be_nil
- end
-
context 'when account is permanently suspended' do
before do
account.suspend!
@@ -108,10 +104,6 @@
expect(response.headers['Vary']).to include 'Signature'
end
- it 'returns Vary header with Signature' do
- expect(response.headers['Vary']).to include 'Signature'
- end
-
context 'when account is permanently suspended' do
before do
account.suspend!
diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb
index 6fad2b85b69288..58c6de42d9c392 100644
--- a/spec/controllers/auth/sessions_controller_spec.rb
+++ b/spec/controllers/auth/sessions_controller_spec.rb
@@ -415,33 +415,4 @@
end
end
end
-
- describe 'GET #webauthn_options' do
- context 'with WebAuthn and OTP enabled as second factor' do
- let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}" }
-
- let(:fake_client) { WebAuthn::FakeClient.new(domain) }
-
- let!(:user) do
- Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
- end
-
- before do
- user.update(webauthn_id: WebAuthn.generate_user_id)
- public_key_credential = WebAuthn::Credential.from_create(fake_client.create)
- user.webauthn_credentials.create(
- nickname: 'SecurityKeyNickname',
- external_id: public_key_credential.id,
- public_key: public_key_credential.public_key,
- sign_count: '1000'
- )
- post :create, params: { user: { email: user.email, password: user.password } }
- end
-
- it 'returns http success' do
- get :webauthn_options
- expect(response).to have_http_status :ok
- end
- end
- end
end
diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb
index 8db534e760036e..89b265e9a020e1 100644
--- a/spec/services/process_mentions_service_spec.rb
+++ b/spec/services/process_mentions_service_spec.rb
@@ -45,24 +45,6 @@
expect(remote_user.mentions.where(status: status).count).to eq 1
end
end
-
- context 'with an IDN TLD' do
- let(:remote_user) { Fabricate(:account, username: 'foo', protocol: :activitypub, domain: 'xn--y9a3aq.xn--y9a3aq', inbox_url: 'http://example.com/inbox') }
- let(:status) { Fabricate(:status, account: account, text: "Hello @foo@հայ.հայ") }
-
- before do
- stub_request(:post, remote_user.inbox_url)
- subject.call(status)
- end
-
- it 'creates a mention' do
- expect(remote_user.mentions.where(status: status).count).to eq 1
- end
-
- it 'sends activity to the inbox' do
- expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once
- end
- end
end
context 'Temporarily-unreachable ActivityPub user' do