diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 106fc8224..1e23cf300 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -46,7 +46,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 +86,7 @@ class Api::V1::StatusesController < Api::BaseController :spoiler_text, :visibility, :scheduled_at, + :quote_id, media_ids: [], poll: [ :multiple, diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 26e7bfab2..fb4d26cf2 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -102,16 +102,14 @@ export function cancelReplyCompose() { }; }; -export function quoteCompose(status, router) { +export function quoteCompose(status, routerHistory) { return (dispatch, getState) => { dispatch({ type: COMPOSE_QUOTE, status: status, }); - if (!getState().getIn(['compose', 'mounted'])) { - router.push('/statuses/new'); - } + ensureComposeIsVisible(getState, routerHistory); }; }; @@ -151,22 +149,13 @@ export function directCompose(account, routerHistory) { export function submitCompose(routerHistory) { return function (dispatch, getState) { - let status = getState().getIn(['compose', 'text'], ''); + const status = getState().getIn(['compose', 'text'], ''); const media = getState().getIn(['compose', 'media_attachments']); - const quoteId = getState().getIn(['compose', 'quote_from'], null); if ((!status || !status.length) && media.size === 0) { return; } - if (quoteId) { - status = [ - status, - "~~~~~~~~~~", - `[${quoteId}][${getState().getIn(['compose', 'quote_from_uri'], null)}]` - ].join("\n"); - } - dispatch(submitComposeRequest()); api(getState).post('/api/v1/statuses', { @@ -177,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/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 5002292b9..d274a0226 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -62,6 +62,8 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.quote = normalOldStatus.get('quote'); + normalStatus.quote_hidden = normalOldStatus.get('quote_hidden'); } else { // If the status has a CW but no contents, treat the CW as if it were the // status' contents, to avoid having a CW toggle with seemingly no effect. @@ -78,6 +80,30 @@ 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('span.invisible'), span => span.remove()); + 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/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/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/status.js b/app/javascript/mastodon/components/status.js index 295e83f58..3019500ec 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -85,6 +85,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 +102,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 @@ -116,6 +118,7 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status), + showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null), statusId: undefined, }; @@ -123,6 +126,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 +138,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 +172,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 +197,10 @@ class Status extends ImmutablePureComponent { this.props.onToggleCollapsed(this._properStatus(), isCollapsed); } + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this._properStatus()); + }; + renderLoadingMediaGallery () { return
; } @@ -279,11 +300,17 @@ class Status extends ImmutablePureComponent { this.node = c; } + _properQuoteStatus () { + const { status } = this.props; + + return status.get('quote'); + } + render () { let media = null; - let statusAvatar, prepend, rebloggedByText; + let statusAvatar, prepend, rebloggedByText, unlistedQuoteText; - const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType } = this.props; let { status, account, ...other } = this.props; @@ -459,6 +486,106 @@ class Status extends ImmutablePureComponent { const visibilityIcon = visibilityIconInfo[status.get('visibility')]; + 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 (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_status.get('visibility') === 'unlisted' && contextType !== 'home') { + unlistedQuoteText = intl.formatMessage({ id: 'status.unlisted_quote', defaultMessage: 'Unlisted quote' }); + quote = ( +
+
+ +
+
+ ); + } else { + quote = ( +
+ + + {quote_media} +
+ ); + } + } + return (
@@ -483,6 +610,7 @@ class Status extends ImmutablePureComponent { + {quote} {media} diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 5cf7ca802..70081f45f 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 = { @@ -38,8 +39,6 @@ export default class StatusContent extends React.PureComponent { } const links = node.querySelectorAll('a'); - const QuoteUrlFormat = /(?:https?|ftp):\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+\/users\/[\w-_]+(\/statuses\/\w+)/; - const quote = node.innerText.match(new RegExp(`\\[(\\w+)\\]\\[${QuoteUrlFormat.source}\\]`)); for (var i = 0; i < links.length; ++i) { let link = links[i]; @@ -48,12 +47,6 @@ export default class StatusContent extends React.PureComponent { } link.classList.add('status-link'); - if (quote) { - if (link.href.match(QuoteUrlFormat)) { - link.addEventListener('click', this.onQuoteClick.bind(this, quote[1]), false); - } - } - let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); if (mention) { @@ -185,7 +178,7 @@ 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'); @@ -238,7 +231,7 @@ export default class StatusContent extends React.PureComponent {
- {!hidden && !!status.get('poll') && } + {!quote && !hidden && !!status.get('poll') && } {renderViewThread && showThreadButton}
@@ -248,7 +241,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') && } + {!quote && !!status.get('poll') && } {renderViewThread && showThreadButton}
, @@ -264,7 +257,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') && } + {!quote && !!status.get('poll') && } {renderViewThread && showThreadButton}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 43acf597d..8e0730625 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -25,6 +25,8 @@ import { hideStatus, revealStatus, toggleStatusCollapse, + hideQuote, + revealQuote, } from '../actions/statuses'; import { unmuteAccount, @@ -220,6 +222,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/features/compose/components/quote_indicator.js b/app/javascript/mastodon/features/compose/components/quote_indicator.js index 515c79fca..a48289de2 100644 --- a/app/javascript/mastodon/features/compose/components/quote_indicator.js +++ b/app/javascript/mastodon/features/compose/components/quote_indicator.js @@ -7,13 +7,14 @@ import DisplayName from '../../../components/display_name'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { isRtl } from '../../../rtl'; +import AttachmentList from 'mastodon/components/attachment_list'; const messages = defineMessages({ cancel: { id: 'quote_indicator.cancel', defaultMessage: 'Cancel' }, }); -@injectIntl -export default class QuoteIndicator extends ImmutablePureComponent { +export default @injectIntl +class QuoteIndicator extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -30,7 +31,7 @@ export default class QuoteIndicator extends ImmutablePureComponent { } handleAccountClick = (e) => { - if (e.button === 0) { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); } @@ -60,6 +61,13 @@ export default class QuoteIndicator extends ImmutablePureComponent {
+ + {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 index eb67f3939..8a3ad4959 100644 --- a/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js +++ b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js @@ -7,7 +7,7 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const mapStateToProps = state => ({ - status: getStatus(state, state.getIn(['compose', 'quote_from'])), + status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 1da4b52a4..013dfed9a 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -283,7 +283,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 317163648..f4d95b4cb 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -72,6 +72,7 @@ export default class Card extends React.PureComponent { defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, sensitive: PropTypes.bool, + quote: PropTypes.bool, }; static defaultProps = { @@ -188,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) { @@ -201,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..ae90a622d 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -46,6 +46,9 @@ class DetailedStatus extends ImmutablePureComponent { available: PropTypes.bool, }), onToggleMediaVisibility: PropTypes.func, + onQuoteToggleHidden: PropTypes.func.isRequired, + showQuoteMedia: PropTypes.bool, + onToggleQuoteMediaVisibility: PropTypes.func, }; state = { @@ -102,6 +105,19 @@ 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 outerStyle = { boxSizing: 'border-box' }; @@ -121,6 +137,73 @@ 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 = ( +