diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 38c9f5a20..3799c3a5e 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -7,8 +7,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index 0cc231840..8bc616216 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -6,8 +6,11 @@ class Api::V1::BookmarksController < Api::BaseController after_action :insert_pagination_headers def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index 2a873696c..194ca0266 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -6,8 +6,11 @@ class Api::V1::FavouritesController < Api::BaseController after_action :insert_pagination_headers def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 9a8c0c161..ae7035916 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -44,10 +44,13 @@ class Api::V1::StatusesController < Api::BaseController loaded_ancestors = cache_collection(ancestors_results, Status) loaded_descendants = cache_collection(descendants_results, Status) - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context.ancestors + @context.descendants + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) + statuses = [@status] + @context.ancestors + @context.descendants + account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + render json: @context, serializer: REST::ContextSerializer, + relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end def create @@ -64,7 +67,8 @@ class Api::V1::StatusesController < Api::BaseController application: doorkeeper_token.application, poll: status_params[:poll], idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true + with_rate_limit: true, + quote_id: status_params[:quote_id].presence, ) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer @@ -128,6 +132,7 @@ class Api::V1::StatusesController < Api::BaseController :visibility, :language, :scheduled_at, + :quote_id, media_ids: [], media_attributes: [ :id, diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index ae6dbcb8b..937ecf6f7 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -6,11 +6,13 @@ class Api::V1::Timelines::HomeController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show - @statuses = load_statuses + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id), status: account_home_feed.regenerating? ? 206 : 200 end diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index a15eae468..731eb6aad 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -9,9 +9,12 @@ class Api::V1::Timelines::ListController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, - relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index d253b744f..61985ffc7 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -5,8 +5,12 @@ class Api::V1::Timelines::PublicController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 64a1db58d..b813ca220 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -5,8 +5,12 @@ class Api::V1::Timelines::TagController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 08cfa9c6d..9303add35 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -23,6 +23,7 @@ module ContextHelper voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, + quoteUrl: { 'quoteUrl' => 'as:quoteUrl' }, }.freeze def full_context diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index c70931489..7b9f23ac1 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -15,7 +15,12 @@ module FormattingHelper module_function :extract_status_plain_text def status_content_format(status) - html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) + html_aware_format( + status.text, + status.local?, + preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), + quote: status.respond_to?(:quote) && status.quote, + ) end def rss_status_content_format(status) diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js index 3c8eec546..44e6c5748 100644 --- a/app/javascript/mastodon/actions/bookmarks.js +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -1,5 +1,7 @@ +import { fetchRelationships } from './accounts'; import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; @@ -20,6 +22,7 @@ export function fetchBookmarkedStatuses() { api(getState).get('/api/v1/bookmarks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchBookmarkedStatusesFail(error)); @@ -61,6 +64,7 @@ export function expandBookmarkedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandBookmarkedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 3756a975b..9a2dd74ab 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -22,6 +22,8 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +export const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; +export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_RESET = 'COMPOSE_RESET'; @@ -120,6 +122,23 @@ export function cancelReplyCompose() { }; } +export function quoteCompose(status, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_QUOTE, + status: status, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +}; + +export function cancelQuoteCompose() { + return { + type: COMPOSE_QUOTE_CANCEL, + }; +}; + export function resetCompose() { return { type: COMPOSE_RESET, @@ -193,6 +212,7 @@ export function submitCompose(routerHistory) { visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), language: getState().getIn(['compose', 'language']), + quote_id: getState().getIn(['compose', 'quote_from'], null), }, headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index 7388e0c58..def7a068d 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -1,5 +1,7 @@ +import { fetchRelationships } from './accounts'; import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; @@ -20,6 +22,7 @@ export function fetchFavouritedStatuses() { api(getState).get('/api/v1/favourites').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchFavouritedStatusesFail(error)); @@ -64,6 +67,7 @@ export function expandFavouritedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandFavouritedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 9c69be601..a1dadff52 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -80,6 +80,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote && status.quote.id) { + processStatus(status.quote); + } + if (status.poll && status.poll.id) { pushUnique(polls, normalizePoll(status.poll)); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 8a22f83fa..d445f436e 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -75,6 +75,8 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.quote = normalOldStatus.get('quote'); + normalStatus.quote_hidden = normalOldStatus.get('quote_hidden'); } else { // If the status has a CW but no contents, treat the CW as if it were the // status' contents, to avoid having a CW toggle with seemingly no effect. @@ -91,6 +93,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; + + if (status.quote) { + normalStatus.quote = normalizeStatus(status.quote, null); + normalStatus.quote_hidden = normalStatus.quote.hidden; + } } return normalStatus; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 275280a53..7b8c9add3 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -28,6 +28,9 @@ export const STATUS_REVEAL = 'STATUS_REVEAL'; export const STATUS_HIDE = 'STATUS_HIDE'; export const STATUS_COLLAPSE = 'STATUS_COLLAPSE'; +export const QUOTE_REVEAL = 'QUOTE_REVEAL'; +export const QUOTE_HIDE = 'QUOTE_HIDE'; + export const REDRAFT = 'REDRAFT'; export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST'; @@ -347,3 +350,25 @@ export const undoStatusTranslation = id => ({ type: STATUS_TRANSLATE_UNDO, id, }); + +export function hideQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_HIDE, + ids, + }; +}; + +export function revealQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_REVEAL, + ids, + }; +}; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 4f772a55f..db86d105a 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,9 +1,11 @@ +import { fetchRelationships } from './accounts'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { submitMarkers } from './markers'; import api, { getLinks } from 'mastodon/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from 'mastodon/compare_id'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import { uniq } from '../utils/uniq'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -39,6 +41,7 @@ export function updateTimeline(timeline, status, accept) { } dispatch(importFetchedStatus(status)); + dispatch(fetchRelationships([status.reblog ? status.reblog.account.id : status.account.id, status.quote ? status.quote.account.id : null].filter(x => x))); dispatch({ type: TIMELINE_UPDATE, @@ -111,6 +114,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => [item.reblog ? item.reblog.account.id : item.account.id, item.quote ? item.quote.account.id : null]).flat().filter(x => x)))); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); if (timelineId === 'home') { diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 659a83375..f075a1fd3 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent { visible: PropTypes.bool, autoplay: PropTypes.bool, onToggleVisibility: PropTypes.func, + quote: PropTypes.bool, }; static defaultProps = { standalone: false, + quote: false, }; state = { @@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent { } render () { - const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props; + const { media, intl, sensitive, height, defaultWidth, standalone, autoplay, quote } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -329,6 +331,10 @@ class MediaGallery extends React.PureComponent { style.height = height; } + if (quote && style.height) { + style.height /= 2; + } + const size = media.take(4).size; const uncached = media.every(attachment => attachment.get('type') === 'unknown'); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 6b8922608..a5556b027 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -1,4 +1,5 @@ import React from 'react'; +import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Avatar from './avatar'; @@ -22,6 +23,29 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_ // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; +export const mapStateToProps = (state, props) => { + let status = props.status; + + if (status === null) { + return null; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + if (status.get('quote', null) === null) { + return { + quoteMuted: !!status.get('quote_id', null), + }; + } + + const id = status.getIn(['quote', 'account', 'id'], null); + return { + quoteMuted: id !== null && (state.getIn(['relationships', id, 'muting']) || state.getIn(['relationships', id, 'blocking']) || state.getIn(['relationships', id, 'blocked_by']) || state.getIn(['relationships', id, 'domain_blocking']) || status.getIn(['quote', 'muted'])), + }; +}; + export const textForScreenReader = (intl, status, rebloggedByText = false) => { const displayName = status.getIn(['account', 'display_name']); @@ -59,7 +83,61 @@ const messages = defineMessages({ edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, }); -export default @injectIntl +export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpandedQuoteToggle, identity, media, router, contextType = 'home') => { + const quoteStatus = status.get('quote', null); + + if (!quoteStatus) { + return null; + } + + const quoteInner = (() => { + const hideUnlisted = quoteStatus.get('visibility') === 'unlisted' + && ['public', 'community', 'hashtag'].includes(contextType); + if (quoteMuted || hideUnlisted) { + const content = (() => { + if (quoteMuted) { + return ( + + ); + } + + return ( + + ); + })(); + + return ( +
+ {content} +
+ ); + } + + return ( +
+
+ {identity(quoteStatus, null, null, true)} +
+ + {media(quoteStatus, true)} +
+ ); + })(); + + return ( +
+ {quoteInner} +
+ ); +}; + +export default @connect(mapStateToProps) @injectIntl class Status extends ImmutablePureComponent { static contextTypes = { @@ -87,7 +165,9 @@ class Status extends ImmutablePureComponent { onToggleCollapsed: PropTypes.func, onTranslate: PropTypes.func, onInteractionModal: PropTypes.func, + onQuoteToggleHidden: PropTypes.func, muted: PropTypes.bool, + quoteMuted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, onMoveUp: PropTypes.func, @@ -103,6 +183,7 @@ class Status extends ImmutablePureComponent { inUse: PropTypes.bool, available: PropTypes.bool, }), + contextType: PropTypes.string, }; // Avoid checking props that are functions (and whose equality will always @@ -114,10 +195,12 @@ class Status extends ImmutablePureComponent { 'hidden', 'unread', 'pictureInPicture', + 'quoteMuted', ]; state = { showMedia: defaultMediaVisibility(this.props.status), + showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null), statusId: undefined, forceFilter: undefined, }; @@ -126,6 +209,7 @@ class Status extends ImmutablePureComponent { if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { return { showMedia: defaultMediaVisibility(nextProps.status), + showQuoteMedia: defaultMediaVisibility(nextProps.status ? nextProps.status.get('quote', null) : null), statusId: nextProps.status.get('id'), }; } else { @@ -137,6 +221,10 @@ class Status extends ImmutablePureComponent { this.setState({ showMedia: !this.state.showMedia }); }; + handleToggleQuoteMediaVisibility = () => { + this.setState({ showQuoteMedia: !this.state.showQuoteMedia }); + } + handleClick = e => { if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) { return; @@ -165,6 +253,15 @@ class Status extends ImmutablePureComponent { this._openProfile(proper); }; + handleQuoteClick = () => { + if (!this.context.router) { + return; + } + + const { status } = this.props; + this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`); + } + handleExpandedToggle = () => { this.props.onToggleHidden(this._properStatus()); }; @@ -177,6 +274,10 @@ class Status extends ImmutablePureComponent { this.props.onTranslate(this._properStatus()); }; + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this._properStatus()); + } + renderLoadingMediaGallery () { return
; } @@ -309,10 +410,9 @@ class Status extends ImmutablePureComponent { }; render () { - let media = null; - let statusAvatar, prepend, rebloggedByText; + let prepend, rebloggedByText; - const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture } = this.props; + const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, quoteMuted, contextType } = this.props; let { status, account, ...other } = this.props; @@ -397,103 +497,123 @@ class Status extends ImmutablePureComponent { ); } - if (pictureInPicture.get('inUse')) { - media = ; - } else if (status.get('media_attachments').size > 0) { - if (this.props.muted) { - media = ( - { + if (pictureInPicture.get('inUse')) { + return ; + } else if (status.get('media_attachments').size > 0) { + if (this.props.muted) { + return ( + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + return ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const attachment = status.getIn(['media_attachments', 0]); + + return ( + + {Component => ( + + )} + + ); + } else { + return ( + + {Component => ( + + )} + + ); + } + } else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) { + return ( + ); - } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - const attachment = status.getIn(['media_attachments', 0]); - - media = ( - - {Component => ( - - )} - - ); - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - const attachment = status.getIn(['media_attachments', 0]); - - media = ( - - {Component => ( - - )} - - ); - } else { - media = ( - - {Component => ( - - )} - - ); } - } else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) { - media = ( - - ); - } - if (account === undefined || account === null) { - statusAvatar = ; - } else { - statusAvatar = ; - } + return null; + }; + + const statusAvatar = (status, account) => { + if (account === undefined || account === null) { + return ; + } else { + return ; + } + }; + + const identity = (status, account) => ( + +
+ {statusAvatar(status, account)} +
+ + +
+ ); const visibilityIconInfo = { 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) }, @@ -516,13 +636,7 @@ class Status extends ImmutablePureComponent { {status.get('edited_at') && *} - -
- {statusAvatar} -
- - -
+ {identity(status, account, false)}
- {media} + {media(status)} + + {quote(status, this.props.muted, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.context.router, contextType)} diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index eeb376561..eae807b5d 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -26,6 +26,8 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, @@ -68,6 +70,7 @@ class StatusActionBar extends ImmutablePureComponent { onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, + onQuote: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, onMention: PropTypes.func, @@ -138,6 +141,10 @@ class StatusActionBar extends ImmutablePureComponent { } }; + handleQuoteClick = () => { + this.props.onQuote(this.props.status, this.context.router.history); + } + handleBookmarkClick = () => { this.props.onBookmark(this.props.status); }; @@ -231,6 +238,14 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onFilter(); }; + static quoteTitle = (intl, messages, publicStatus) => { + if (publicStatus) { + return intl.formatMessage(messages.quote); + } else { + return intl.formatMessage(messages.cannot_quote); + } + } + render () { const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.context.identity; @@ -361,7 +376,8 @@ class StatusActionBar extends ImmutablePureComponent {
- + + {shareButton} diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index ece54621f..1d2e6bc41 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -9,6 +9,7 @@ import Icon from 'mastodon/components/icon'; import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) +const QUOTE_MAX_HEIGHT = 112; // 22px * 5 (+ 2px padding at the top) class TranslateButton extends React.PureComponent { @@ -64,6 +65,7 @@ class StatusContent extends React.PureComponent { collapsable: PropTypes.bool, onCollapsedToggle: PropTypes.func, intl: PropTypes.object, + quote: PropTypes.bool, }; state = { @@ -107,12 +109,12 @@ class StatusContent extends React.PureComponent { } if (status.get('collapsed', null) === null && onCollapsedToggle) { - const { collapsable, onClick } = this.props; + const { collapsable, onClick, quote } = this.props; const collapsed = collapsable && onClick - && node.clientHeight > MAX_HEIGHT + && node.clientHeight > (quote ? QUOTE_MAX_HEIGHT : MAX_HEIGHT) && status.get('spoiler_text').length === 0; onCollapsedToggle(collapsed); @@ -216,7 +218,7 @@ class StatusContent extends React.PureComponent { }; render () { - const { status, intl } = this.props; + const { status, intl, quote } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const renderReadMore = this.props.onClick && status.get('collapsed'); @@ -245,6 +247,12 @@ class StatusContent extends React.PureComponent { ); + if (quote) { + const doc = new DOMParser().parseFromString(content.__html, 'text/html').documentElement; + Array.from(doc.querySelectorAll('br')).forEach(nl => nl.replaceWith(' ')); + content.__html = doc.outerHTML; + } + if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 580f409e9..ea52c9186 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -4,6 +4,7 @@ import Status from '../components/status'; import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; import { replyCompose, + quoteCompose, mentionCompose, directCompose, } from '../actions/compose'; @@ -27,6 +28,8 @@ import { editStatus, translateStatus, undoStatusTranslation, + hideQuote, + revealQuote, } from '../actions/statuses'; import { unmuteAccount, @@ -58,6 +61,8 @@ const messages = defineMessages({ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' }, editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' }, + quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); @@ -107,6 +112,22 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + onQuote (status, router) { + dispatch((_, getState) => { + let state = getState(); + + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.quoteMessage), + confirm: intl.formatMessage(messages.quoteConfirm), + onConfirm: () => dispatch(quoteCompose(status, router)), + })); + } else { + dispatch(quoteCompose(status, router)); + } + }); + }, + onFavourite (status) { if (status.get('favourited')) { dispatch(unfavourite(status)); @@ -234,6 +255,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ dispatch(toggleStatusCollapse(status.get('id'), isCollapsed)); }, + onQuoteToggleHidden (status) { + if (status.get('quote_hidden')) { + dispatch(revealQuote(status.get('id'))); + } else { + dispatch(hideQuote(status.get('id'))); + } + }, + onBlockDomain (domain) { dispatch(openModal('CONFIRM', { message: {domain} }} />, diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index a55658360..461834848 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -50,6 +50,7 @@ class Audio extends React.PureComponent { volume: PropTypes.number, muted: PropTypes.bool, deployPictureInPicture: PropTypes.func, + quote: PropTypes.bool, }; state = { @@ -94,7 +95,11 @@ class Audio extends React.PureComponent { _setDimensions () { const width = this.player.offsetWidth; - const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); + let height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); + + if (this.props.quote) { + height /= 2; + } if (this.props.cacheWidth) { this.props.cacheWidth(width); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 8da737ec0..cf3ff8aec 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -235,6 +235,7 @@ class ComposeForm extends ImmutablePureComponent { +
{ @@ -37,7 +39,7 @@ class ReplyIndicator extends ImmutablePureComponent { }; render () { - const { status, intl } = this.props; + const { status, intl, quote } = this.props; if (!status) { return null; @@ -46,7 +48,7 @@ class ReplyIndicator extends ImmutablePureComponent { const content = { __html: status.get('contentHtml') }; return ( -
+
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js index a1302b2d4..2eea64f96 100644 --- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -1,22 +1,23 @@ import { connect } from 'react-redux'; -import { cancelReplyCompose } from '../../../actions/compose'; +import { cancelReplyCompose, cancelQuoteCompose } from '../../../actions/compose'; import { makeGetStatus } from '../../../selectors'; import ReplyIndicator from '../components/reply_indicator'; const makeMapStateToProps = () => { const getStatus = makeGetStatus(); - const mapStateToProps = state => { + const mapStateToProps = (state, props) => { let statusId = state.getIn(['compose', 'id'], null); let editing = true; if (statusId === null) { - statusId = state.getIn(['compose', 'in_reply_to']); + statusId = state.getIn(['compose', props.quote ? 'quote_from' : 'in_reply_to']); editing = false; } return { status: getStatus(state, { id: statusId }), + quote: props.quote, editing, }; }; @@ -26,8 +27,8 @@ const makeMapStateToProps = () => { const mapDispatchToProps = dispatch => ({ - onCancel () { - dispatch(cancelReplyCompose()); + onCancel (quote) { + dispatch(quote ? cancelQuoteCompose() : cancelReplyCompose()); }, }); diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js index 3f59b891b..f09a57be3 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js @@ -4,10 +4,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import IconButton from 'mastodon/components/icon_button'; +import StatusActionBar from 'mastodon/components/status_action_bar'; import classNames from 'classnames'; import { me, boostModal } from 'mastodon/initial_state'; import { defineMessages, injectIntl } from 'react-intl'; -import { replyCompose } from 'mastodon/actions/compose'; +import { replyCompose, quoteCompose } from 'mastodon/actions/compose'; import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions'; import { makeGetStatus } from 'mastodon/selectors'; import { initBoostModal } from 'mastodon/actions/boosts'; @@ -20,9 +21,13 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' }, + quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, open: { id: 'status.open', defaultMessage: 'Expand this status' }, }); @@ -135,6 +140,31 @@ class Footer extends ImmutablePureComponent { } }; + _performQuote = () => { + const { dispatch, status, onClose } = this.props; + const { router } = this.context; + + if (onClose) { + onClose(); + } + + dispatch(quoteCompose(status, router.history)); + } + + handleQuoteClick = () => { + const { dispatch, askReplyConfirmation, intl } = this.props; + + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.quoteMessage), + confirm: intl.formatMessage(messages.quoteConfirm), + onConfirm: this._performQuote, + })); + } else { + this._performQuote(); + } + } + handleOpenClick = e => { const { router } = this.context; diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 0d4767331..d183aaa75 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import IconButton from '../../../components/icon_button'; +import StatusActionBar from '../../../components/status_action_bar'; import ImmutablePropTypes from 'react-immutable-proptypes'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; @@ -20,6 +21,8 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, more: { id: 'status.more', defaultMessage: 'More' }, @@ -61,6 +64,7 @@ class ActionBar extends React.PureComponent { relationship: ImmutablePropTypes.map, onReply: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired, + onQuote: PropTypes.func, onFavourite: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, @@ -88,6 +92,10 @@ class ActionBar extends React.PureComponent { this.props.onReblog(this.props.status, e); }; + handleQuoteClick = () => { + this.props.onQuote(this.props.status, this.context.router.history); + } + handleFavouriteClick = () => { this.props.onFavourite(this.props.status); }; @@ -286,6 +294,7 @@ class ActionBar extends React.PureComponent {
+
{shareButton} diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 34fac1010..a7394c5b4 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -68,6 +68,7 @@ export default class Card extends React.PureComponent { defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, sensitive: PropTypes.bool, + quote: PropTypes.bool, }; static defaultProps = { @@ -184,7 +185,7 @@ export default class Card extends React.PureComponent { } render () { - const { card, maxDescription, compact } = this.props; + const { card, maxDescription, compact, quote } = this.props; const { width, embedded, revealed } = this.state; if (card === null) { @@ -197,7 +198,11 @@ export default class Card extends React.PureComponent { const className = classnames('status-card', { horizontal, compact, interactive }); const title = interactive ? {card.get('title')} : {card.get('title')}; const ratio = card.get('width') / card.get('height'); - const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); + let height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); + + if (quote && height) { + height /= 2; + } const description = (
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 116d9f6b2..34f734cea 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -1,8 +1,10 @@ import React from 'react'; +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; +import { mapStateToProps, quote } from '../../../components/status'; import StatusContent from '../../../components/status_content'; import MediaGallery from '../../../components/media_gallery'; import { Link } from 'react-router-dom'; @@ -25,7 +27,7 @@ const messages = defineMessages({ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); -export default @injectIntl +export default @connect(mapStateToProps) @injectIntl class DetailedStatus extends ImmutablePureComponent { static contextTypes = { @@ -37,17 +39,21 @@ class DetailedStatus extends ImmutablePureComponent { onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, onToggleHidden: PropTypes.func.isRequired, + onQuoteToggleHidden: PropTypes.func.isRequired, onTranslate: PropTypes.func.isRequired, measureHeight: PropTypes.bool, onHeightChange: PropTypes.func, domain: PropTypes.string.isRequired, compact: PropTypes.bool, + quoteMuted: PropTypes.bool, showMedia: PropTypes.bool, + showQuoteMedia: PropTypes.bool, pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, }), onToggleMediaVisibility: PropTypes.func, + onQuoteToggleMediaVisibility: PropTypes.func, }; state = { @@ -56,8 +62,9 @@ class DetailedStatus extends ImmutablePureComponent { handleAccountClick = (e) => { if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) { + const acct = e.currentTarget.getAttribute('data-acct'); e.preventDefault(); - this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); + this.context.router.history.push(`/@${acct}`); } e.stopPropagation(); @@ -71,6 +78,19 @@ class DetailedStatus extends ImmutablePureComponent { this.props.onToggleHidden(this.props.status); }; + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this.props.status); + } + + handleQuoteClick = () => { + if (!this.context.router) { + return; + } + + const { status } = this.props; + this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`); + } + _measureHeight (heightJustChanged) { if (this.props.measureHeight && this.node) { scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 })); @@ -112,13 +132,12 @@ class DetailedStatus extends ImmutablePureComponent { render () { const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const outerStyle = { boxSizing: 'border-box' }; - const { intl, compact, pictureInPicture } = this.props; + const { intl, compact, pictureInPicture, quoteMuted } = this.props; if (!status) { return null; } - let media = ''; let applicationLink = ''; let reblogLink = ''; let reblogIcon = 'retweet'; @@ -129,63 +148,84 @@ class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } - if (pictureInPicture.get('inUse')) { - media = ; - } else if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - const attachment = status.getIn(['media_attachments', 0]); + const identity = (status, _0, _1, quote = false) => ( + +
+ +
+ ); - media = ( -