From 2aa6ec14fe031c61b4b775133021f088485b6b9e Mon Sep 17 00:00:00 2001 From: kyori19 Date: Sat, 2 Jan 2021 17:38:40 +0900 Subject: [PATCH] Implement quote feature --- .../api/v1/accounts/statuses_controller.rb | 7 +- .../api/v1/bookmarks_controller.rb | 7 +- .../api/v1/favourites_controller.rb | 7 +- app/controllers/api/v1/statuses_controller.rb | 13 +- .../api/v1/timelines/home_controller.rb | 4 +- .../api/v1/timelines/list_controller.rb | 5 +- .../api/v1/timelines/public_controller.rb | 8 +- .../api/v1/timelines/tag_controller.rb | 8 +- app/javascript/mastodon/actions/bookmarks.js | 4 + app/javascript/mastodon/actions/compose.js | 20 ++ app/javascript/mastodon/actions/favourites.js | 4 + .../mastodon/actions/importer/index.js | 4 + .../mastodon/actions/importer/normalizer.js | 7 + .../mastodon/actions/notifications.js | 2 +- app/javascript/mastodon/actions/statuses.js | 25 ++ app/javascript/mastodon/actions/timelines.js | 4 + .../mastodon/components/media_gallery.js | 8 +- app/javascript/mastodon/components/status.js | 320 ++++++++++++------ .../mastodon/components/status_action_bar.js | 16 + .../mastodon/components/status_content.js | 12 +- .../mastodon/containers/status_container.js | 29 ++ .../mastodon/containers/timeline_container.js | 4 +- .../mastodon/features/audio/index.js | 7 +- .../compose/components/compose_form.js | 1 + .../compose/components/reply_indicator.js | 6 +- .../containers/reply_indicator_container.js | 11 +- .../picture_in_picture/components/footer.js | 32 +- .../features/status/components/action_bar.js | 9 + .../features/status/components/card.js | 9 +- .../status/components/detailed_status.js | 149 +++++--- .../containers/detailed_status_container.js | 10 + .../mastodon/features/status/index.js | 41 ++- .../mastodon/features/video/index.js | 8 +- .../mastodon/locales/defaultMessages.json | 26 +- app/javascript/mastodon/locales/en.json | 6 + app/javascript/mastodon/locales/ja.json | 6 + app/javascript/mastodon/reducers/compose.js | 37 +- app/javascript/mastodon/reducers/search.js | 2 + app/javascript/mastodon/reducers/statuses.js | 10 + app/javascript/mastodon/selectors/index.js | 36 +- app/javascript/mastodon/utils/uniq.js | 1 + app/javascript/packs/public.js | 23 ++ .../styles/mastodon/components.scss | 83 ++++- app/javascript/styles/mastodon/statuses.scss | 17 +- app/lib/activitypub/activity/create.rb | 19 ++ app/lib/activitypub/adapter.rb | 1 + app/lib/activitypub/case_transform.rb | 2 + app/lib/formatter.rb | 18 + app/lib/sanitize_config.rb | 1 + app/models/status.rb | 19 +- .../activitypub/note_serializer.rb | 13 + app/serializers/rest/instance_serializer.rb | 7 +- app/serializers/rest/status_serializer.rb | 29 ++ app/services/fetch_link_card_service.rb | 2 +- app/services/post_status_service.rb | 28 ++ app/views/statuses/_author.html.haml | 20 ++ app/views/statuses/_detailed_status.html.haml | 43 +-- app/views/statuses/_media.html.haml | 18 + app/views/statuses/_quote.html.haml | 6 + app/views/statuses/_simple_status.html.haml | 46 +-- app/views/statuses/_text.html.haml | 10 + ...20180419235016_add_quote_id_to_statuses.rb | 5 + ...01102028_add_index_to_statuses_quote_id.rb | 9 + db/schema.rb | 2 + 64 files changed, 1068 insertions(+), 278 deletions(-) create mode 100644 app/javascript/mastodon/utils/uniq.js create mode 100644 app/views/statuses/_author.html.haml create mode 100644 app/views/statuses/_media.html.haml create mode 100644 app/views/statuses/_quote.html.haml create mode 100644 app/views/statuses/_text.html.haml create mode 100644 db/migrate/20180419235016_add_quote_id_to_statuses.rb create mode 100644 db/migrate/20200301102028_add_index_to_statuses_quote_id.rb diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 92ccb8061..29d62fba1 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -7,8 +7,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index aa3fb88f0..46621ed5d 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -6,8 +6,11 @@ class Api::V1::BookmarksController < Api::BaseController after_action :insert_pagination_headers def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index 21836bc17..45c7c3c3a 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -6,8 +6,11 @@ class Api::V1::FavouritesController < Api::BaseController after_action :insert_pagination_headers def index - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 106fc8224..7f9e79cf1 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -28,10 +28,13 @@ class Api::V1::StatusesController < Api::BaseController loaded_ancestors = cache_collection(ancestors_results, Status) loaded_descendants = cache_collection(descendants_results, Status) - @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context.ancestors + @context.descendants + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) + statuses = [@status] + @context.ancestors + @context.descendants + account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq - render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) + render json: @context, serializer: REST::ContextSerializer, + relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end def create @@ -46,7 +49,8 @@ class Api::V1::StatusesController < Api::BaseController application: doorkeeper_token.application, poll: status_params[:poll], idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true) + with_rate_limit: true, + quote_id: status_params[:quote_id].presence) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer end @@ -85,6 +89,7 @@ class Api::V1::StatusesController < Api::BaseController :spoiler_text, :visibility, :scheduled_at, + :quote_id, media_ids: [], poll: [ :multiple, diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index ae6dbcb8b..937ecf6f7 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -6,11 +6,13 @@ class Api::V1::Timelines::HomeController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show - @statuses = load_statuses + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id), status: account_home_feed.regenerating? ? 206 : 200 end diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb index a15eae468..731eb6aad 100644 --- a/app/controllers/api/v1/timelines/list_controller.rb +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -9,9 +9,12 @@ class Api::V1::Timelines::ListController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + render json: @statuses, each_serializer: REST::StatusSerializer, - relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index d253b744f..61985ffc7 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -5,8 +5,12 @@ class Api::V1::Timelines::PublicController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 64a1db58d..b813ca220 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -5,8 +5,12 @@ class Api::V1::Timelines::TagController < Api::BaseController after_action :insert_pagination_headers, unless: -> { @statuses.empty? } def show - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + @statuses = load_statuses + account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq + + render json: @statuses, each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), + account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id) end private diff --git a/app/javascript/mastodon/actions/bookmarks.js b/app/javascript/mastodon/actions/bookmarks.js index 544ed2ff2..b3e0a2ab4 100644 --- a/app/javascript/mastodon/actions/bookmarks.js +++ b/app/javascript/mastodon/actions/bookmarks.js @@ -1,5 +1,7 @@ +import { fetchRelationships } from './accounts'; import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST'; export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS'; @@ -20,6 +22,7 @@ export function fetchBookmarkedStatuses() { api(getState).get('/api/v1/bookmarks').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchBookmarkedStatusesFail(error)); @@ -61,6 +64,7 @@ export function expandBookmarkedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandBookmarkedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 891403969..b011fdcc6 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -19,6 +19,8 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +export const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; +export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_RESET = 'COMPOSE_RESET'; @@ -100,6 +102,23 @@ export function cancelReplyCompose() { }; }; +export function quoteCompose(status, routerHistory) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_QUOTE, + status: status, + }); + + ensureComposeIsVisible(getState, routerHistory); + }; +}; + +export function cancelQuoteCompose() { + return { + type: COMPOSE_QUOTE_CANCEL, + }; +}; + export function resetCompose() { return { type: COMPOSE_RESET, @@ -147,6 +166,7 @@ export function submitCompose(routerHistory) { spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', visibility: getState().getIn(['compose', 'privacy']), poll: getState().getIn(['compose', 'poll'], null), + quote_id: getState().getIn(['compose', 'quote_from'], null), }, { headers: { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index 9448b1efe..9b28ac4c4 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -1,5 +1,7 @@ +import { fetchRelationships } from './accounts'; import api, { getLinks } from '../api'; import { importFetchedStatuses } from './importer'; +import { uniq } from '../utils/uniq'; export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; @@ -20,6 +22,7 @@ export function fetchFavouritedStatuses() { api(getState).get('/api/v1/favourites').then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(fetchFavouritedStatusesFail(error)); @@ -64,6 +67,7 @@ export function expandFavouritedStatuses() { api(getState).get(url).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id)))); dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); }).catch(error => { dispatch(expandFavouritedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index f4372fb31..35032fc70 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -70,6 +70,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote && status.quote.id) { + processStatus(status.quote); + } + if (status.poll && status.poll.id) { pushUnique(polls, normalizePoll(status.poll)); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index dca44917a..32979fbb2 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -60,6 +60,8 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.quote = normalOldStatus.get('quote'); + normalStatus.quote_hidden = normalOldStatus.get('quote_hidden'); } else { const spoilerText = normalStatus.spoiler_text || ''; const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -69,6 +71,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; + + if (status.quote) { + normalStatus.quote = normalizeStatus(status.quote, null); + normalStatus.quote_hidden = normalStatus.quote.hidden; + } } return normalStatus; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 3464ac995..22f1e0cbb 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -46,7 +46,7 @@ defineMessages({ }); const fetchRelatedRelationships = (dispatch, notifications) => { - const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); + const accountIds = notifications.map(item => item.account.id); if (accountIds.length > 0) { dispatch(fetchRelationships(accountIds)); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3fc7c0702..d457bcffc 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -28,6 +28,9 @@ export const STATUS_REVEAL = 'STATUS_REVEAL'; export const STATUS_HIDE = 'STATUS_HIDE'; export const STATUS_COLLAPSE = 'STATUS_COLLAPSE'; +export const QUOTE_REVEAL = 'QUOTE_REVEAL'; +export const QUOTE_HIDE = 'QUOTE_HIDE'; + export const REDRAFT = 'REDRAFT'; export function fetchStatusRequest(id, skipLoading) { @@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) { isCollapsed, }; } + +export function hideQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_HIDE, + ids, + }; +}; + +export function revealQuote(ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + return { + type: QUOTE_REVEAL, + ids, + }; +}; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index de1725acf..029924fb2 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,9 +1,11 @@ +import { fetchRelationships } from './accounts'; import { importFetchedStatus, importFetchedStatuses } from './importer'; import { submitMarkers } from './markers'; import api, { getLinks } from 'mastodon/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import compareId from 'mastodon/compare_id'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import { uniq } from '../utils/uniq'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -30,6 +32,7 @@ export function updateTimeline(timeline, status, accept) { } dispatch(importFetchedStatus(status)); + dispatch(fetchRelationships([status.reblog ? status.reblog.account.id : status.account.id, status.quote ? status.quote.account.id : null].filter(x => x))); dispatch({ type: TIMELINE_UPDATE, @@ -102,6 +105,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); + dispatch(fetchRelationships(uniq(response.data.map(item => [item.reblog ? item.reblog.account.id : item.account.id, item.quote ? item.quote.account.id : null]).flat().filter(x => x)))); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); if (timelineId === 'home') { diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 0a8f42585..7d372bde4 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent { visible: PropTypes.bool, autoplay: PropTypes.bool, onToggleVisibility: PropTypes.func, + quote: PropTypes.bool, }; static defaultProps = { standalone: false, + quote: false, }; state = { @@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent { } render () { - const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props; + const { media, intl, sensitive, height, defaultWidth, standalone, autoplay, quote } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -329,6 +331,10 @@ class MediaGallery extends React.PureComponent { style.height = height; } + if (quote && style.height) { + style.height /= 2; + } + const size = media.take(4).size; const uncached = media.every(attachment => attachment.get('type') === 'unknown'); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 295e83f58..c5d55b437 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -1,4 +1,5 @@ import React from 'react'; +import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Avatar from './avatar'; @@ -23,6 +24,29 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_ // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; +export const mapStateToProps = (state, props) => { + let status = props.status; + + if (status === null) { + return null; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + status = status.get('reblog'); + } + + if (status.get('quote', null) === null) { + return { + quoteMuted: !!status.get('quote_id', null), + }; + } + + const id = status.getIn(['quote', 'account', 'id'], null); + return { + quoteMuted: id !== null && (state.getIn(['relationships', id, 'muting']) || state.getIn(['relationships', id, 'blocking']) || state.getIn(['relationships', id, 'blocked_by']) || state.getIn(['relationships', id, 'domain_blocking']) || status.getIn(['quote', 'muted'])), + }; +}; + export const textForScreenReader = (intl, status, rebloggedByText = false) => { const displayName = status.getIn(['account', 'display_name']); @@ -59,7 +83,61 @@ const messages = defineMessages({ direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); -export default @injectIntl +export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpandedQuoteToggle, identity, media, router, contextType = 'home') => { + const quoteStatus = status.get('quote', null); + + if (!quoteStatus) { + return null; + } + + const quoteInner = (() => { + const hideUnlisted = quoteStatus.get('visibility') === 'unlisted' + && ['public', 'community', 'hashtag'].includes(contextType); + if (quoteMuted || hideUnlisted) { + const content = (() => { + if (quoteMuted) { + return ( + + ); + } + + 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 = { @@ -85,7 +163,9 @@ class Status extends ImmutablePureComponent { onHeightChange: PropTypes.func, onToggleHidden: PropTypes.func, onToggleCollapsed: PropTypes.func, + onQuoteToggleHidden: PropTypes.func, muted: PropTypes.bool, + quoteMuted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, onMoveUp: PropTypes.func, @@ -101,6 +181,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 +193,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, }; @@ -123,6 +206,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 { @@ -134,6 +218,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 +252,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 +277,10 @@ class Status extends ImmutablePureComponent { this.props.onToggleCollapsed(this._properStatus(), isCollapsed); } + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this._properStatus()); + } + renderLoadingMediaGallery () { return
; } @@ -280,10 +381,9 @@ class Status extends ImmutablePureComponent { } render () { - let media = null; - let statusAvatar, prepend, rebloggedByText; + let prepend, rebloggedByText; - const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, quoteMuted, contextType } = this.props; let { status, account, ...other } = this.props; @@ -354,101 +454,121 @@ 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) { - 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')) { + 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')) { - media = ( - - ); - } - if (otherAccounts && otherAccounts.size > 0) { - statusAvatar = ; - } else if (account === undefined || account === null) { - statusAvatar = ; - } else { - statusAvatar = ; - } + return null; + }; + + const statusAvatar = (status, account, otherAccounts, quote = false) => { + if (otherAccounts && otherAccounts.size > 0) { + return ; + } else if (account === undefined || account === null) { + return ; + } else { + return ; + } + }; + + const identity = (status, account, otherAccounts, quote = false) => ( + +
+ {statusAvatar(status, account, otherAccounts, quote)} +
+ + +
+ ); const visibilityIconInfo = { 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) }, @@ -472,18 +592,14 @@ class Status extends ImmutablePureComponent { - -
- {statusAvatar} -
- - -
+ {identity(status, account, otherAccounts, 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 66b5a17ac..8e76d4fd0 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -24,6 +24,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' }, @@ -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, @@ -125,6 +128,10 @@ class StatusActionBar extends ImmutablePureComponent { window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); } + handleQuoteClick = () => { + this.props.onQuote(this.props.status, this.context.router.history); + } + handleBookmarkClick = () => { this.props.onBookmark(this.props.status); } @@ -220,6 +227,14 @@ class StatusActionBar extends ImmutablePureComponent { } } + 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, scrollKey } = this.props; @@ -322,6 +337,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 185a2a663..8fc50e8dc 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 } from 'mastodon/initial_state'; const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) +const QUOTE_MAX_HEIGHT = 102; // 20px * 5 (+ 2px padding at the top) export default class StatusContent extends React.PureComponent { @@ -24,6 +25,7 @@ export default class StatusContent extends React.PureComponent { onClick: PropTypes.func, collapsable: PropTypes.bool, onCollapsedToggle: PropTypes.func, + quote: PropTypes.bool, }; state = { @@ -66,7 +68,7 @@ export default class StatusContent extends React.PureComponent { let collapsed = this.props.collapsable && this.props.onClick - && node.clientHeight > MAX_HEIGHT + && node.clientHeight > (this.props.quote ? QUOTE_MAX_HEIGHT : MAX_HEIGHT) && this.props.status.get('spoiler_text').length === 0; if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed); @@ -173,7 +175,7 @@ export default class StatusContent extends React.PureComponent { } render () { - const { status } = this.props; + const { status, quote } = this.props; if (status.get('content').length === 0) { return null; @@ -203,6 +205,12 @@ export default 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 d6bcb8973..596bd71ab 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, @@ -49,6 +52,8 @@ const messages = defineMessages({ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' }, + quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); @@ -98,6 +103,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onQuote (status, router) { + dispatch((_, getState) => { + let state = getState(); + + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.quoteMessage), + confirm: intl.formatMessage(messages.quoteConfirm), + onConfirm: () => dispatch(quoteCompose(status, router)), + })); + } else { + dispatch(quoteCompose(status, router)); + } + }); + }, + onFavourite (status) { if (status.get('favourited')) { dispatch(unfavourite(status)); @@ -198,6 +219,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ 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/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/audio/index.js b/app/javascript/mastodon/features/audio/index.js index c47f55dd1..0be7ea18d 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -42,6 +42,7 @@ class Audio extends React.PureComponent { volume: PropTypes.number, muted: PropTypes.bool, deployPictureInPicture: PropTypes.func, + quote: PropTypes.bool, }; state = { @@ -83,7 +84,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 8af806ec4..79b0f226b 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -201,6 +201,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 5eb1eb72a..1c68c6eba 100644 --- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -1,13 +1,14 @@ 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 => ({ - status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }), + const mapStateToProps = (state, props) => ({ + status: getStatus(state, { id: state.getIn(['compose', props.quote ? 'quote_from' : 'in_reply_to']) }), + quote: props.quote, }); return mapStateToProps; @@ -15,8 +16,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 1b1ec6d54..e28e131e8 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 { openModal } from 'mastodon/actions/modal'; @@ -19,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' }, + 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' }, }); @@ -106,6 +111,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 d7d504bc5..36910472d 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'; @@ -18,6 +19,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' }, @@ -56,6 +59,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, @@ -82,6 +86,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); } @@ -276,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..323968c6e 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 043a749ed..7470041bd 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'; @@ -24,7 +26,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 = { @@ -36,16 +38,20 @@ class DetailedStatus extends ImmutablePureComponent { onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, onToggleHidden: PropTypes.func.isRequired, + onQuoteToggleHidden: 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 = { @@ -54,8 +60,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(); @@ -69,6 +76,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 })); @@ -105,13 +125,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'; @@ -121,59 +140,80 @@ 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 = ( -