diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 92ccb8061..f301666db 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -7,8 +7,9 @@ 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 aa3fb88f0..f666a1d8d 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -6,8 +6,9 @@ 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 21836bc17..651a057d5 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -6,8 +6,9 @@ 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 106fc8224..d8f4db42f 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -30,8 +30,9 @@ class Api::V1::StatusesController < Api::BaseController @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 @@ -46,7 +47,8 @@ class Api::V1::StatusesController < Api::BaseController application: doorkeeper_token.application, poll: status_params[:poll], idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true) + with_rate_limit: true, + quote_id: status_params[:quote_id].presence) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer end @@ -85,6 +87,7 @@ class Api::V1::StatusesController < Api::BaseController :spoiler_text, :visibility, :scheduled_at, + :quote_id, media_ids: [], poll: [ :multiple, diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index ae6dbcb8b..c07e1a820 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 a15eae468..bc13ed5ec 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 d253b744f..1a778012b 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -5,8 +5,10 @@ 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 64a1db58d..c7afd4580 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -5,8 +5,9 @@ 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/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js index 544ed2ff2..b3e0a2ab4 100644 --- a/app/javascript/mastodon/actions/bookmarks.js +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -1,5 +1,7 @@ +import { fetchRelationships } from './accounts'; import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; @@ -20,6 +22,7 @@ export function fetchBookmarkedStatuses() { api(getState).get('/api/v1/bookmarks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchBookmarkedStatusesFail(error)); @@ -61,6 +64,7 @@ export function expandBookmarkedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandBookmarkedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/boosts.js b/app/javascript/mastodon/actions/boosts.js index 6e14065d6..c0f0f3acc 100644 --- a/app/javascript/mastodon/actions/boosts.js +++ b/app/javascript/mastodon/actions/boosts.js @@ -11,7 +11,7 @@ export function initBoostModal(props) { dispatch({ type: BOOSTS_INIT_MODAL, - privacy + privacy, }); dispatch(openModal('BOOST', props)); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 891403969..fb4d26cf2 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -20,6 +20,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'; @@ -100,6 +102,23 @@ export function cancelReplyCompose() { }; }; +export function quoteCompose(status, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_QUOTE, + status: status, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +}; + +export function cancelQuoteCompose() { + return { + type: COMPOSE_QUOTE_CANCEL, + }; +}; + export function resetCompose() { return { type: COMPOSE_RESET, @@ -147,6 +166,7 @@ export function submitCompose(routerHistory) { spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), + quote_id: getState().getIn(['compose', 'quote_from'], null), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index 9448b1efe..9b28ac4c4 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -1,5 +1,7 @@ +import { fetchRelationships } from './accounts'; import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; @@ -20,6 +22,7 @@ export function fetchFavouritedStatuses() { api(getState).get('/api/v1/favourites').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchFavouritedStatusesFail(error)); @@ -64,6 +67,7 @@ export function expandFavouritedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandFavouritedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index f4372fb31..35032fc70 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -70,6 +70,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote && status.quote.id) { + processStatus(status.quote); + } + if (status.poll && status.poll.id) { pushUnique(polls, normalizePoll(status.poll)); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index abd5681d4..1b9d781a7 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -60,7 +60,10 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.contentHtml = normalOldStatus.get('contentHtml'); 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. @@ -77,6 +80,29 @@ 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 && status.quote.id) { + const quote_spoilerText = status.quote.spoiler_text || ''; + const quote_searchContent = [quote_spoilerText, status.quote.content].join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); + + const quote_emojiMap = makeEmojiMap(normalStatus.quote); + + const quote_account_emojiMap = makeEmojiMap(status.quote.account); + const displayName = normalStatus.quote.account.display_name.length === 0 ? normalStatus.quote.account.username : normalStatus.quote.account.display_name; + normalStatus.quote.account.display_name_html = emojify(escapeTextContentForBrowser(displayName), quote_account_emojiMap); + normalStatus.quote.search_index = domParser.parseFromString(quote_searchContent, 'text/html').documentElement.textContent; + let docElem = domParser.parseFromString(normalStatus.quote.content, 'text/html').documentElement; + Array.from(docElem.querySelectorAll('p,br'), line => { + let parentNode = line.parentNode; + if (line.nextSibling) { + parentNode.insertBefore(document.createTextNode(' '), line.nextSibling); + } + }); + let _contentHtml = docElem.textContent; + normalStatus.quote.contentHtml = '

'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'

'; + normalStatus.quote.spoilerHtml = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap); + normalStatus.quote_hidden = expandSpoilers ? false : quote_spoilerText.length > 0 || normalStatus.quote.sensitive; + } } return normalStatus; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 3464ac995..22f1e0cbb 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -46,7 +46,7 @@ defineMessages({ }); const fetchRelatedRelationships = (dispatch, notifications) => { - const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); + const accountIds = notifications.map(item => item.account.id); if (accountIds.length > 0) { dispatch(fetchRelationships(accountIds)); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3fc7c0702..0c520a796 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -30,6 +30,9 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE'; export const REDRAFT = 'REDRAFT'; +export const QUOTE_REVEAL = 'QUOTE_REVEAL'; +export const QUOTE_HIDE = 'QUOTE_HIDE'; + export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) { isCollapsed, }; } + +export function hideQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_HIDE, + ids, + }; +}; + +export function revealQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_REVEAL, + ids, + }; +}; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 31ae09e4a..d82bf1d0d 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, @@ -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 2e7ce2e60..5c303a1a0 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; @@ -332,6 +334,10 @@ class MediaGallery extends React.PureComponent { const size = media.take(4).size; const uncached = media.every(attachment => attachment.get('type') === 'unknown'); + if (quote && style.height) { + style.height /= 2; + } + if (standalone && this.isFullSizeEligible()) { children = ; } else { diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js index 477f56e13..1126b0121 100644 --- a/app/javascript/mastodon/components/poll.js +++ b/app/javascript/mastodon/components/poll.js @@ -39,6 +39,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 }; @@ -59,7 +62,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 295e83f58..a08bb291a 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']); @@ -59,7 +83,8 @@ const messages = defineMessages({ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); -export default @injectIntl +export default @connect(mapStateToProps) +@injectIntl class Status extends ImmutablePureComponent { static contextTypes = { @@ -70,6 +95,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, @@ -85,6 +111,7 @@ class Status extends ImmutablePureComponent { onHeightChange: PropTypes.func, onToggleHidden: PropTypes.func, onToggleCollapsed: PropTypes.func, + onQuoteToggleHidden: PropTypes.func, muted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, @@ -101,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 @@ -112,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, }; @@ -123,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 { @@ -134,6 +165,10 @@ class Status extends ImmutablePureComponent { this.setState({ showMedia: !this.state.showMedia }); } + handleToggleQuoteMediaVisibility = () => { + this.setState({ showQuoteMedia: !this.state.showQuoteMedia }); + } + handleClick = () => { if (this.props.onClick) { this.props.onClick(); @@ -164,6 +199,15 @@ class Status extends ImmutablePureComponent { } } + 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']))}`); + } + handleAccountClick = (e) => { if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { const id = e.currentTarget.getAttribute('data-id'); @@ -180,6 +224,10 @@ class Status extends ImmutablePureComponent { this.props.onToggleCollapsed(this._properStatus(), isCollapsed); } + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this._properStatus()); + }; + renderLoadingMediaGallery () { return
; } @@ -197,10 +245,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(); @@ -275,6 +332,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; } @@ -283,7 +350,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; @@ -354,10 +421,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 (
@@ -483,6 +676,7 @@ class Status extends ImmutablePureComponent { + {quote} {media} diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 7dbbb99cc..4c4b233b1 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -23,7 +23,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' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, @@ -61,6 +63,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, @@ -137,6 +140,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); } @@ -334,7 +341,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 bf21a9fd6..71fbbd7c5 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 = { @@ -125,6 +126,15 @@ export default class StatusContent extends React.PureComponent { } } + onQuoteClick = (statusId, e) => { + let statusUrl = `/statuses/${statusId}`; + + if (this.context.router && e.button === 0) { + e.preventDefault(); + this.context.router.history.push(statusUrl); + } + } + handleMouseDown = (e) => { this.startXY = [e.clientX, e.clientY]; } @@ -168,11 +178,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 +205,16 @@ export default class StatusContent extends React.PureComponent { ); + const showPollButton = ( + + ); + + const pollContainer = ( + + ); + if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; @@ -221,7 +242,7 @@ export default class StatusContent extends React.PureComponent {
- {!hidden && !!status.get('poll') && } + {!hidden && renderShowPoll && quote ? showPollButton : pollContainer} {renderViewThread && showThreadButton}
@@ -231,7 +252,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') && } + {renderShowPoll && quote ? showPollButton : pollContainer} {renderViewThread && showThreadButton}
, @@ -247,7 +268,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 9abdec138..ce055cd96 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'; @@ -24,6 +25,8 @@ import { hideStatus, revealStatus, toggleStatusCollapse, + hideQuote, + revealQuote, } from '../actions/statuses'; import { unmuteAccount, @@ -50,6 +53,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' }, }); @@ -99,6 +104,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)); @@ -215,6 +236,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); }, + onQuoteToggleHidden (status) { + if (status.get('quote_hidden')) { + dispatch(revealQuote(status.get('id'))); + } else { + dispatch(hideQuote(status.get('id'))); + } + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js index ed8095f90..768169458 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/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 4456935e3..81eb74c32 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'; @@ -220,6 +221,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(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + 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 000000000..8a3ad4959 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js @@ -0,0 +1,24 @@ +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 => ({ + status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelQuoteCompose()); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator); diff --git a/app/javascript/mastodon/features/follow_recommendations/index.js b/app/javascript/mastodon/features/follow_recommendations/index.js index a35ff3e82..26c8b2471 100644 --- a/app/javascript/mastodon/features/follow_recommendations/index.js +++ b/app/javascript/mastodon/features/follow_recommendations/index.js @@ -76,7 +76,7 @@ class FollowRecommendations extends ImmutablePureComponent { return ( -
+

diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 555d5e1b5..5c984197f 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -2,7 +2,6 @@ import { connect } from 'react-redux'; import { makeGetNotification, makeGetStatus } from '../../../selectors'; import Notification from '../components/notification'; import { initBoostModal } from '../../../actions/boosts'; -import { openModal } from '../../../actions/modal'; import { mentionCompose } from '../../../actions/compose'; import { reblog, 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 1ecb18bf8..c89b86ffe 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' }, }); @@ -119,6 +123,31 @@ class Footer extends ImmutablePureComponent { router.history.push(`/statuses/${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; @@ -152,6 +181,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 ffa2510c0..61833f37b 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -17,7 +17,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' }, @@ -56,6 +58,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, @@ -82,6 +85,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); } @@ -277,6 +284,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 90f9ae7ae..f4d95b4cb 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,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 043a749ed..9d16a9aa9 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'; @@ -24,7 +25,31 @@ const messages = defineMessages({ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); -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 = { @@ -33,8 +58,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, @@ -46,6 +74,9 @@ class DetailedStatus extends ImmutablePureComponent { available: PropTypes.bool, }), onToggleMediaVisibility: PropTypes.func, + onQuoteToggleHidden: PropTypes.func.isRequired, + showQuoteMedia: PropTypes.bool, + onToggleQuoteMediaVisibility: PropTypes.func, }; state = { @@ -54,8 +85,9 @@ class DetailedStatus extends ImmutablePureComponent { handleAccountClick = (e) => { if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) { + const id = e.currentTarget.getAttribute('data-id'); e.preventDefault(); - this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + this.context.router.history.push(`/accounts/${id}`); } e.stopPropagation(); @@ -65,6 +97,10 @@ class DetailedStatus extends ImmutablePureComponent { 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); } @@ -102,8 +138,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.onQuoteToggleHidden(this.props.status); + } + + handleQuoteClick = () => { + if (!this.context.router) { + return; + } + + const { status } = this.props; + this.context.router.history.push(`/statuses/${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; @@ -121,6 +171,95 @@ 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 = ( +