diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index bba3c0651beedb365d8554bd3bb0057d801e9db4..fc94c13c58b4e86f71dc40bfcc3f06e7e8650e7d 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -44,7 +44,8 @@ class Api::V1::StatusesController < Api::BaseController scheduled_at: status_params[:scheduled_at], application: doorkeeper_token.application, poll: status_params[:poll], - idempotency: request.headers['Idempotency-Key']) + idempotency: request.headers['Idempotency-Key'], + quote_id: status_params[:quote_id].blank? ? nil : status_params[:quote_id]) render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer end @@ -76,6 +77,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 c3c6ff1a1de704f37cefc0576f6d2cda22e37988..e721d8a428b924f515d92543e66983c10c2c1b7f 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'; @@ -95,6 +97,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, @@ -142,6 +161,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 78f321da4d12568c55a6a0f72aace7877d9012c5..1d31ad07e37dc9d70b107a5026433eaf5e705de4 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(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); @@ -69,6 +71,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(/<br\s*\/?>/g, '\n').replace(/<\/p><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 = '<p>'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'</p>'; + 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 5640201c621f0f494afb96d731ace606a899c69b..1eba35bd904b6ea55511ab1d90a2757a92e414b9 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -32,6 +32,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, @@ -329,3 +332,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 cfe164a507d8220ea6dda2f234e4c893f0277c8c..9c7cbb1300429be68dab3e702ffa475bf79b3aba 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -255,10 +255,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 = { @@ -303,7 +305,7 @@ class MediaGallery extends React.PureComponent { } render () { - const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props; + const { media, intl, sensitive, height, defaultWidth, standalone, quote, autoplay } = this.props; const { visible } = this.state; const width = this.state.width || defaultWidth; @@ -325,6 +327,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 /= 2; + } + if (standalone && this.isFullSizeEligible()) { children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; } else { diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 0dc00cb98d59b5f1c93e934a58856f4074078cf8..2177f0cfabe0b5c189d7eee3f24e66f059c0cc5e 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -77,6 +77,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, @@ -87,6 +88,7 @@ class Status extends ImmutablePureComponent { updateScrollBottom: PropTypes.func, cacheMediaWidth: PropTypes.func, cachedMediaWidth: PropTypes.number, + contextType: PropTypes.string, }; // Avoid checking props that are functions (and whose equality will always @@ -148,6 +150,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'); @@ -164,6 +175,10 @@ class Status extends ImmutablePureComponent { this.props.onToggleCollapsed(this._properStatus(), isCollapsed); } + handleExpandedQuoteToggle = () => { + this.props.onQuoteToggleHidden(this._properStatus()); + }; + renderLoadingMediaGallery () { return <div className='media-gallery' style={{ height: '110px' }} />; } @@ -253,11 +268,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 } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, contextType } = this.props; let { status, account, ...other } = this.props; @@ -413,6 +434,53 @@ class Status extends ImmutablePureComponent { statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; } + 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_status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + quote_media = ( + <AttachmentList + compact + media={quote_status.get('media_attachments')} + /> + ); + } else { + quote_media = ( + <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > + {Component => <Component media={quote_status.get('media_attachments')} sensitive={quote_status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} quote />} + </Bundle> + ); + } + } + + if (quote_status.get('visibility') === 'unlisted' && contextType !== 'home') { + unlistedQuoteText = intl.formatMessage({ id: 'status.unlisted_quote', defaultMessage: 'Unlisted quote' }); + quote = ( + <div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}> + <div className={classNames('status__content unlisted-quote', { 'status__content--with-action': this.context.router })}> + <strong onClick={this.handleQuoteClick}>{unlistedQuoteText}</strong> + </div> + </div> + ); + } else { + quote = ( + <div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}> + <div className='status__info'> + <a onClick={this.handleAccountClick} target='_blank' data-id={quote_status.getIn(['account', 'id'])} href={quote_status.getIn(['account', 'url'])} title={quote_status.getIn(['account', 'acct'])} className='status__display-name'> + <div className='status__avatar'><Avatar account={quote_status.get('account')} size={18} /></div> + <DisplayName account={quote_status.get('account')} /> + </a> + </div> + <StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} /> + {quote_media} + </div> + ); + } + } + return ( <HotKeys handlers={handlers}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}> @@ -435,6 +503,7 @@ class Status extends ImmutablePureComponent { <StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} /> {media} + {quote} {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && ( <button className='status__content__read-more-button' onClick={this.handleClick}> diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 4b3c79d0daec82bf46bec05dd278a34a62455bdf..9acb8f9403009a1815d01a09031f10eaa9333f28 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -23,6 +23,7 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, 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' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' }, @@ -70,6 +71,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 +139,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); } @@ -328,6 +334,7 @@ class StatusActionBar extends ImmutablePureComponent { <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> <IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /> {shareButton} <div className='status__action-bar-dropdown'> diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 2ba3a3123da224eb826f7935f136b26087db48d5..adb7a4e8b4b81b4cecb529515d2259bea04f228b 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 } 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, @@ -95,6 +98,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onQuote (status, router) { + dispatch(quoteCompose(status, router)); + }, + onFavourite (status) { if (status.get('favourited')) { dispatch(unfavourite(status)); @@ -206,6 +213,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onUnblockDomain (domain) { dispatch(unblockDomain(domain)); }, + + onQuoteToggleHidden (status) { + if (status.get('quote_hidden')) { + dispatch(revealQuote(status.get('id'))); + } else { + dispatch(hideQuote(status.get('id'))); + } + }, }); diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 47e189251c9f0d1ab8babe42b69576002db3bfc1..843ae31a666fe4d3834a394c621bb9e6060a7955 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'; @@ -195,6 +196,7 @@ class ComposeForm extends ImmutablePureComponent { <WarningContainer /> <ReplyIndicatorContainer /> + <QuoteIndicatorContainer /> <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}> <AutosuggestInput diff --git a/app/javascript/mastodon/features/compose/components/quote_indicator.js b/app/javascript/mastodon/features/compose/components/quote_indicator.js new file mode 100644 index 0000000000000000000000000000000000000000..4a2f92ff1380448e8b4711d478c448e5cead5292 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/quote_indicator.js @@ -0,0 +1,75 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from '../../../components/avatar'; +import IconButton from '../../../components/icon_button'; +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 { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map, + onCancel: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + 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') }; + const style = { + direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr', + }; + + return ( + <div className='quote-indicator'> + <div className='quote-indicator__header'> + <div className='quote-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> + + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='quote-indicator__display-name'> + <div className='quote-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div> + <DisplayName account={status.get('account')} /> + </a> + </div> + + <div className='quote-indicator__content' style={style} dangerouslySetInnerHTML={content} /> + + {status.get('media_attachments').size > 0 && ( + <AttachmentList + compact + media={status.get('media_attachments')} + /> + )} + </div> + ); + } + +} 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 0000000000000000000000000000000000000000..8a3ad4959a244a6d2525f26eba5e4558c35643d5 --- /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/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index bf6469f2fc31045dcebb933f8674aed41ccd01e3..b841ad3f9f9eb05b59085555aec555aeef0cadfd 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -17,6 +17,7 @@ const messages = defineMessages({ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, 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' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, @@ -54,6 +55,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, @@ -80,6 +82,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); } @@ -271,6 +277,7 @@ class ActionBar extends React.PureComponent { <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div> <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div> <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div> + <div className='detailed-status__button'><IconButton disabled={reblog_disabled} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div> {shareButton} <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 2993fe29aaa54a589d687ac5527b7d9673b82577..6dcb875a719f861703e1772c2b11cb91915a6985 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -56,6 +56,10 @@ const addAutoPlay = html => { export default class Card extends React.PureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { card: ImmutablePropTypes.map, maxDescription: PropTypes.number, diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index d5bc82735985bfd595aa599699c81de89a9f10bd..307d9dbc45ac16b4fb76af8bd0ec2fa09cf72e66 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -32,6 +32,7 @@ export default class DetailedStatus extends ImmutablePureComponent { compact: PropTypes.bool, showMedia: PropTypes.bool, onToggleMediaVisibility: PropTypes.func, + onQuoteToggleHidden: PropTypes.func.isRequired, }; state = { @@ -88,6 +89,19 @@ export default 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' }; @@ -107,6 +121,36 @@ export default 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) { + quote_media = ( + <MediaGallery + standalone + sensitive={quote_status.get('sensitive')} + media={quote_status.get('media_attachments')} + height={300} + onOpenMedia={this.props.onOpenMedia} + quote + /> + ); + } + + quote = ( + <div className='quote-status'> + <div className='status__info'> + <div className='status__avatar'><Avatar account={quote_status.get('account')} size={18} /></div> + <DisplayName account={quote_status.get('account')} /> + </div> + <StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} /> + {quote_media} + </div> + ); + } + if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { const attachment = status.getIn(['media_attachments', 0]); @@ -218,6 +262,7 @@ export default class DetailedStatus extends ImmutablePureComponent { <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} /> {media} + {quote} <div className='detailed-status__meta'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index c058120d6b07d6f8e250e8d0f2c8193e5ce20a35..0ff7350b0f7116d7ff7af25b0840cadb4b309971 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -22,6 +22,7 @@ import { } from '../../actions/interactions'; import { replyCompose, + quoteCompose, mentionCompose, directCompose, } from '../../actions/compose'; @@ -31,6 +32,8 @@ import { deleteStatus, hideStatus, revealStatus, + hideQuote, + revealQuote, } from '../../actions/statuses'; import { unblockAccount, @@ -251,6 +254,10 @@ class Status extends ImmutablePureComponent { } } + handleQuoteClick = (status) => { + this.props.dispatch(quoteCompose(status, this.context.router.history)); + } + handleDeleteClick = (status, history, withRedraft = false) => { const { dispatch, intl } = this.props; @@ -317,6 +324,14 @@ class Status extends ImmutablePureComponent { } } + handleQuoteToggleHidden = (status) => { + if (status.get('quote_hidden')) { + this.props.dispatch(revealQuote(status.get('id'))); + } else { + this.props.dispatch(hideQuote(status.get('id'))); + } + } + handleToggleAll = () => { const { status, ancestorsIds, descendantsIds } = this.props; const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS()); @@ -550,6 +565,7 @@ class Status extends ImmutablePureComponent { domain={domain} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} + onQuoteToggleHidden={this.handleQuoteToggleHidden} /> <ActionBar @@ -559,6 +575,7 @@ class Status extends ImmutablePureComponent { onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onBookmark={this.handleBookmarkClick} + onQuote={this.handleQuoteClick} onDelete={this.handleDeleteClick} onDirect={this.handleDirectClick} onMention={this.handleMentionClick} diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 0cb67c08c0c8e1643174ffdb183c2729602ebdcc..b00cbdbc1d30caf34ff02df82fa825fe255fb525 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -394,6 +394,14 @@ "defaultMessage": "This post cannot be boosted", "id": "status.cannot_reblog" }, + { + "defaultMessage": "Quote", + "id": "status.quote" + }, + { + "defaultMessage": "Unlisted quote", + "id": "status.unlisted_quote" + }, { "defaultMessage": "Favourite", "id": "status.favourite" @@ -1103,6 +1111,15 @@ ], "path": "app/javascript/mastodon/features/compose/components/privacy_dropdown.json" }, + { + "descriptors": [ + { + "defaultMessage": "Cancel", + "id": "quote_indicator.cancel" + } + ], + "path": "app/javascript/mastodon/features/compose/components/quote_indicator.json" + }, { "descriptors": [ { diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0494a9cf5593323ba2db3de7251758c8f4441f3e..9be7ade34b0d00e08033adfa0cdb6cefc73d6a73 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -324,6 +324,7 @@ "privacy.public.short": "Public", "privacy.unlisted.long": "Do not post to public timelines", "privacy.unlisted.short": "Unlisted", + "quote_indicator.cancel": "Cancel", "refresh": "Refresh", "regeneration_indicator.label": "Loading…", "regeneration_indicator.sublabel": "Your home feed is being prepared!", @@ -374,6 +375,7 @@ "status.pin": "Pin on profile", "status.pinned": "Pinned toot", "status.read_more": "Read more", + "status.quote": "Quote", "status.reblog": "Boost", "status.reblog_private": "Boost to original audience", "status.reblogged_by": "{name} boosted", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 4cb405a8956c3547eaad9c453849be614af16e7d..b93af6c9564e79c92e72ccf68c21e61bf6ea2f7b 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -324,6 +324,7 @@ "privacy.public.short": "公開", "privacy.unlisted.long": "公開TLã§è¡¨ç¤ºã—ãªã„", "privacy.unlisted.short": "未åŽè¼‰", + "quote_indicator.cancel": "ã‚ャンセル", "refresh": "æ›´æ–°", "regeneration_indicator.label": "èªã¿è¾¼ã¿ä¸â€¦", "regeneration_indicator.sublabel": "ãƒ›ãƒ¼ãƒ ã‚¿ã‚¤ãƒ ãƒ©ã‚¤ãƒ³ã¯æº–å‚™ä¸ã§ã™ï¼", @@ -374,6 +375,8 @@ "status.pin": "プãƒãƒ•ィールã«å›ºå®šè¡¨ç¤º", "status.pinned": "固定ã•れãŸãƒˆã‚¥ãƒ¼ãƒˆ", "status.read_more": "ã‚‚ã£ã¨è¦‹ã‚‹", + "status.quote": "引用", + "status.unlisted_quote": "未åŽè¼‰ã®å¼•用", "status.reblog": "ブースト", "status.reblog_private": "ブースト", "status.reblogged_by": "{name}ã•ã‚“ãŒãƒ–ースト", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index c6653fe4cdf9128c3c0b0ca59249090c09aebc2b..b84c095ac4287a892cf9206c25d0d2bd9a1e5e40 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -5,6 +5,8 @@ import { COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, COMPOSE_DIRECT, + COMPOSE_QUOTE, + COMPOSE_QUOTE_CANCEL, COMPOSE_MENTION, COMPOSE_SUBMIT_REQUEST, COMPOSE_SUBMIT_SUCCESS, @@ -55,6 +57,8 @@ const initialState = ImmutableMap({ caretPosition: null, preselectDate: null, in_reply_to: null, + quote_from: null, + quote_from_url: null, is_composing: false, is_submitting: false, is_changing_upload: false, @@ -96,6 +100,7 @@ function clearAll(state) { map.set('is_submitting', false); map.set('is_changing_upload', false); map.set('in_reply_to', null); + map.set('quote_from', null); map.set('privacy', state.get('default_privacy')); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); @@ -241,6 +246,17 @@ const updateSuggestionTags = (state, token) => { }); }; +const rejectQuoteAltText = html => { + const fragment = domParser.parseFromString(html, 'text/html').documentElement; + + const quote_inline = fragment.querySelector('span.quote-inline'); + if (quote_inline) { + quote_inline.remove(); + } + + return fragment.innerHTML; +}; + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: @@ -287,6 +303,8 @@ export default function compose(state = initialState, action) { case COMPOSE_REPLY: return state.withMutations(map => { map.set('in_reply_to', action.status.get('id')); + map.set('quote_from', null); + map.set('quote_from_url', null); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); map.set('focusDate', new Date()); @@ -294,6 +312,25 @@ export default function compose(state = initialState, action) { map.set('preselectDate', new Date()); map.set('idempotencyKey', uuid()); + if (action.status.get('spoiler_text').length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.status.get('spoiler_text')); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + }); + case COMPOSE_QUOTE: + return state.withMutations(map => { + map.set('in_reply_to', null); + map.set('quote_from', action.status.get('id')); + map.set('quote_from_url', action.status.get('url')); + map.set('text', ''); + map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + if (action.status.get('spoiler_text').length > 0) { map.set('spoiler', true); map.set('spoiler_text', action.status.get('spoiler_text')); @@ -303,9 +340,12 @@ export default function compose(state = initialState, action) { } }); case COMPOSE_REPLY_CANCEL: + case COMPOSE_QUOTE_CANCEL: case COMPOSE_RESET: return state.withMutations(map => { map.set('in_reply_to', null); + map.set('quote_from', null); + map.set('quote_from_url', null); map.set('text', ''); map.set('spoiler', false); map.set('spoiler_text', ''); @@ -378,8 +418,10 @@ export default function compose(state = initialState, action) { })); case REDRAFT: return state.withMutations(map => { - map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); + map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status)))); map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('quote_from', action.status.getIn(['quote', 'id'])); + map.set('quote_from_url', action.status.getIn(['quote', 'url'])); map.set('privacy', action.status.get('visibility')); map.set('media_attachments', action.status.get('media_attachments')); map.set('focusDate', new Date()); diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js index 875b2d92b39d4ba6cd6ad2d9ca091c55729ac411..f139a124bd1f0cad59f56a1456c9321a4522e716 100644 --- a/app/javascript/mastodon/reducers/search.js +++ b/app/javascript/mastodon/reducers/search.js @@ -9,6 +9,7 @@ import { COMPOSE_MENTION, COMPOSE_REPLY, COMPOSE_DIRECT, + COMPOSE_QUOTE, } from '../actions/compose'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; @@ -36,6 +37,7 @@ export default function search(state = initialState, action) { case COMPOSE_REPLY: case COMPOSE_MENTION: case COMPOSE_DIRECT: + case COMPOSE_QUOTE: return state.set('hidden', true); case SEARCH_FETCH_SUCCESS: return state.set('results', ImmutableMap({ diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 398a48cff8f226b54c4df9aec865214d0c68d642..4d75ca8866747c0c5c53bd8194e438cbb87d0e13 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -13,6 +13,8 @@ import { STATUS_REVEAL, STATUS_HIDE, STATUS_COLLAPSE, + QUOTE_REVEAL, + QUOTE_HIDE, } from '../actions/statuses'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; @@ -76,6 +78,14 @@ export default function statuses(state = initialState, action) { }); case STATUS_COLLAPSE: return state.setIn([action.id, 'collapsed'], action.isCollapsed); + case QUOTE_REVEAL: + return state.withMutations(map => { + action.ids.forEach(id => map.setIn([id, 'quote_hidden'], false)); + }); + case QUOTE_HIDE: + return state.withMutations(map => { + action.ids.forEach(id => map.setIn([id, 'quote_hidden'], true)); + }); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); default: diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 9bc6214af28b2b08832cc7f85d834f3597624002..bb5048cb8f2d0e141642aaa95079d3785a142a19 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -249,6 +249,19 @@ function main() { target.style.display = 'block'; } }); + + delegate(document, '.quote-status', 'click', ({ target }) => { + if (target.closest('.status__content__spoiler-link') || + target.closest('.media-gallery')) + return false; + const url = target.closest('.status__display-name') ? target.closest('.status__display-name').getAttribute('href') : target.closest('.quote-status').getAttribute('dataurl'); + if (window.location.hostname === url.split('/')[2].split(':')[0]) { + window.location.href = url; + } else { + window.open(url, 'blank'); + } + return false; + }); } loadPolyfills() diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 94671c350d32542b1ab9bb75a01ed0563712f82b..ee56d3bae6c3bbc6c972be3d1b93784ca87f5b1b 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -723,26 +723,37 @@ } .reply-indicator { + background: $ui-primary-color; +} + +.quote-indicator { + background: $success-green; +} + +.reply-indicator, +.quote-indicator { border-radius: 4px; margin-bottom: 10px; - background: $ui-primary-color; padding: 10px; min-height: 23px; overflow-y: auto; flex: 0 2 auto; } -.reply-indicator__header { +.reply-indicator__header, +.quote-indicator__header { margin-bottom: 5px; overflow: hidden; } -.reply-indicator__cancel { +.reply-indicator__cancel, +.quote-indicator__cancel { float: right; line-height: 24px; } -.reply-indicator__display-name { +.reply-indicator__display-name, +.quote-indicator__display-name { color: $inverted-text-color; display: block; max-width: 100%; @@ -752,7 +763,8 @@ text-decoration: none; } -.reply-indicator__display-avatar { +.reply-indicator__display-avatar, +.quote-indicator__display-avatar { float: left; margin-right: 5px; } @@ -762,7 +774,8 @@ } .status__content, -.reply-indicator__content { +.reply-indicator__content, +.quote-indicator__content { position: relative; font-size: 15px; line-height: 20px; @@ -907,6 +920,56 @@ border-bottom: 1px solid lighten($ui-base-color, 8%); } +.quote-inline { + display: none; +} + +.quote-status { + border: solid 1px $ui-base-lighter-color; + border-radius: 4px; + padding: 5px; + margin-top: 8px; + position: relative; + + & > .unlisted-quote { + color: $dark-text-color; + font-weight: 500; + } + + .status__avatar { + height: 18px; + width: 18px; + position: absolute; + top: 5px; + left: 5px; + cursor: pointer; + + & > div { + width: 18px; + height: 18px; + } + } + + .display-name__account { + color: $ui-base-lighter-color; + } + + .display-name { + padding-left: 20px; + } +} + +@media screen and (min-width: 630px) { + .columns-area--mobile .quote-status .status__avatar { + top: 5px; + left: 5px; + } +} + +.muted .quote-status .display-name { + color: $ui-base-lighter-color; +} + .status__prepend-icon-wrapper { left: -26px; position: absolute; @@ -1197,7 +1260,8 @@ margin-left: 6px; } -.reply-indicator__content { +.reply-indicator__content, +.quote-indicator__content { color: $inverted-text-color; font-size: 14px; diff --git a/app/javascript/styles/mastodon/statuses.scss b/app/javascript/styles/mastodon/statuses.scss index 19ce0ab8f06924409caa904f2df8b42151d6e2a1..2558c99b300bb2a9f7fdef01c6a0049f9e555b41 100644 --- a/app/javascript/styles/mastodon/statuses.scss +++ b/app/javascript/styles/mastodon/statuses.scss @@ -63,6 +63,28 @@ } } + .status.quote-status { + border: solid 1px $ui-base-lighter-color; + border-radius: 4px; + padding: 5px; + margin-top: 15px; + cursor: pointer; + width: 100%; + + .status__avatar { + height: 18px; + width: 18px; + position: absolute; + top: 5px; + left: 5px; + + & > div { + width: 18px; + height: 18px; + } + } + } + @media screen and (max-width: 740px) { .detailed-status, .status, diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index c55cfe08eae8909584c389c63064d20c4972c11d..ed6039ffcd130428a7dc126866dea801b8f9d92a 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -78,6 +78,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity conversation: conversation_from_uri(@object['conversation']), media_attachment_ids: process_attachments.take(4).map(&:id), poll: process_poll, + quote: quote_from_url(@object['quoteUrl']), } end end @@ -457,4 +458,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def poll_lock_options { redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" } end + + def quote_from_url(url) + return nil if url.nil? + quote = ResolveURLService.new.call(url) + status_from_uri(quote.uri) if quote + end end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 78138fb73f8c3753ea7516195fb9c3098594aab8..b40eb072d61adaf0aaabb92feaf19763b2c2b5a5 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -22,6 +22,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' }, discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, + quoteUrl: { 'quoteUrl' => 'as:quoteUrl' }, }.freeze def self.default_key_transform diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index c771dcaaa0d4c178b075b0a708fae84a527ad501..c5fa072d2b68c4e0aa38f78ff11ebbe5fec55ff2 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -39,11 +39,24 @@ class Formatter html = encode_and_link_urls(html, linkable_accounts) html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] html = simple_format(html, {}, sanitize: false) + html = quotify(html, status, options) if status.quote? && !options[:escape_quotify] html = html.delete("\n") html.html_safe # rubocop:disable Rails/OutputSafety end + def format_in_quote(status, **options) + html = format(status) + return '' if html.empty? + doc = Nokogiri::HTML.parse(html, nil, 'utf-8') + doc.search('span.invisible').remove + html = doc.css('body')[0].inner_html + html.sub!(/^<p>(.+)<\/p>$/, '\1') + html = Sanitize.clean(html).delete("\n").truncate(150) + html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify] + html.html_safe + end + def reformat(html) sanitize(html, Sanitize::Config::MASTODON_STRICT) end @@ -188,6 +201,15 @@ class Formatter html end + def quotify(html, status, options) + #options[:escape_quotify] = true + #quote_content = format_in_quote(status.quote, options) + url = ActivityPub::TagManager.instance.url_for(status.quote) + link = encode_and_link_urls(url) + #html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: #{quote_content} [#{link}]</span>\\1") + html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: [#{link}]</span>\\1") + end + def rewrite(text, entities) text = text.to_s diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb index e2480376e4ac50127dfccf4db14378e0f7c65771..2c96454dff8e1bb0af0c5ae2b552d81913e2db9e 100644 --- a/app/lib/sanitize_config.rb +++ b/app/lib/sanitize_config.rb @@ -14,6 +14,7 @@ class Sanitize next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes next true if e =~ /^(mention|hashtag)$/ # semantic classes next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes + next true if e =~ /^quote-inline$/ # quote inline classes end node['class'] = class_list.join(' ') diff --git a/app/models/status.rb b/app/models/status.rb index 1e630196bde5b9069f38edfec16bf6728682cc12..bf2294a5b1480e9939890ac868ac6800d1c56fac 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -23,6 +23,7 @@ # in_reply_to_account_id :bigint(8) # poll_id :bigint(8) # deleted_at :datetime +# quote_id :bigint(8) # class Status < ApplicationRecord @@ -52,6 +53,7 @@ class Status < ApplicationRecord belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true + belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quoted, optional: true has_many :favourites, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy @@ -60,6 +62,7 @@ class Status < ApplicationRecord has_many :mentions, dependent: :destroy, inverse_of: :status has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status has_many :media_attachments, dependent: :nullify + has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards @@ -160,6 +163,10 @@ class Status < ApplicationRecord !reblog_of_id.nil? end + def quote? + !quote_id.nil? && quote + end + def within_realtime_window? created_at >= REAL_TIME_WINDOW.ago end @@ -232,7 +239,7 @@ class Status < ApplicationRecord fields = [spoiler_text, text] fields += preloadable_poll.options unless preloadable_poll.nil? - @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : []) end def mark_for_mass_destruction! diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 110621a28bf88ec6498b776368bac696bfa0fdc5..6ee13d09e1691a189ea964a3eac9fafabc22b695 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -7,7 +7,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer :in_reply_to, :published, :url, :attributed_to, :to, :cc, :sensitive, :atom_uri, :in_reply_to_atom_uri, - :conversation + :conversation, :quote_url attribute :content attribute :content_map, if: :language? @@ -121,6 +121,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end end + def quote_url + object.quote? ? ActivityPub::TagManager.instance.uri_for(object.quote) : nil + end + def local? object.account.local? end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 1ed8948da1f2ee9d290c7df36a50e4ad3af4deae..0be7babe3bcff0f4eb3e799702140990a809689d 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -18,6 +18,7 @@ class REST::StatusSerializer < ActiveModel::Serializer belongs_to :reblog, serializer: REST::StatusSerializer belongs_to :application, if: :show_application? belongs_to :account, serializer: REST::AccountSerializer + belongs_to :quote, serializer: REST::StatusSerializer has_many :media_attachments, serializer: REST::MediaAttachmentSerializer has_many :ordered_mentions, key: :mentions diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 91141c1f545f398c0f09e4cf83ea9692e9cc63f2..59ccead789c6e8c347a957a66336689f85b23fbb 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -66,7 +66,7 @@ class FetchLinkCardService < BaseService urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).normalize } else html = Nokogiri::HTML(@status.text) - links = html.css('a') + links = html.css(':not(.quote-inline) > a') urls = links.map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.compact.map(&:normalize).compact end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index a0a650d62411735c9da77f9ffc118e31065d61bd..6e78c91f821ac9bd291c0799d43b1a1f9395d066 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -160,6 +160,7 @@ class PostStatusService < BaseService visibility: @visibility, language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account), application: @options[:application], + quote_id: @options[:quote_id], }.compact end diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index 021390e474ffda3c9ce6ce6c996109d0b5bb9dd8..8252232f44191b24466ed6b2eeed62e6e45ca175 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -26,6 +26,9 @@ = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } + - if status.quote? + = render partial: "statuses/quote_status", locals: {status: status.quote} + - if !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first diff --git a/app/views/statuses/_quote_status.html.haml b/app/views/statuses/_quote_status.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..7c9cb3c925a9158e74d579f2b017078d52ff36d2 --- /dev/null +++ b/app/views/statuses/_quote_status.html.haml @@ -0,0 +1,18 @@ +.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) } + = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name p-author h-card', target: stream_link_target, rel: 'noopener' do + .status__avatar + %div + = image_tag status.account.avatar(:original), width: 18, height: 18, alt: '', class: 'account__avatar u-photo' + %span.display-name + %strong.p-name.emojify= display_name(status.account, custom_emojify: true) + %span= acct(status.account) + + .status__content.p-name.emojify< + - if status.spoiler_text? + %p{ style: 'margin-bottom: 0' }< + %span.p-summary> #{Formatter.instance.format_spoiler(status)} + %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') + .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }> + %p>= Formatter.instance.format_in_quote(status, custom_emojify: true) + - unless status.media_attachments.empty? + %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: true) }} diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index 66c9412afcc6d77f0c2c8133ba8731ccb296c148..33b5140e39eece65417c58f43716162cad94dba7 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -30,6 +30,9 @@ = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } + - if status.quote? + = render partial: "statuses/quote_status", locals: {status: status.quote} + - if !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 7e3828f7e017d2e6fba0b18fb1399f0a819a9433..c20766c3a456694a101cc56a2425dafbd20184de 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -125,14 +125,14 @@ "fingerprint": "67afc0d5f7775fa5bd91d1912e1b5505aeedef61876347546fa20f92fd6915e6", "check_name": "Render", "message": "Render path contains parameter value", - "file": "app/views/stream_entries/embed.html.haml", + "file": "app/views/statuses/embed.html.haml", "line": 3, "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :centered => true, :autoplay => ActiveModel::Type::Boolean.new.cast(params[:autoplay]) })", - "render_path": [{"type":"controller","class":"StatusesController","method":"embed","line":63,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"stream_entries/embed","file":"/home/eugr/Projects/mastodon/app/views/stream_entries/embed.html.haml"}}], + "code": "render(action => \"statuses/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :centered => true, :autoplay => ActiveModel::Type::Boolean.new.cast(params[:autoplay]) })", + "render_path": [{"type":"controller","class":"StatusesController","method":"embed","line":63,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"statuses/embed","file":"/home/eugr/Projects/mastodon/app/views/statuses/embed.html.haml"}}], "location": { "type": "template", - "template": "stream_entries/embed" + "template": "statuses/embed" }, "user_input": "params[:id]", "confidence": "Weak", @@ -282,14 +282,14 @@ "fingerprint": "fbd0fc59adb5c6d44b60e02debb31d3af11719f534c9881e21435bbff87404d6", "check_name": "Render", "message": "Render path contains parameter value", - "file": "app/views/stream_entries/show.html.haml", + "file": "app/views/statuses/show.html.haml", "line": 23, "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(partial => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })", - "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":34,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"stream_entries/show","file":"/home/eugr/Projects/mastodon/app/views/stream_entries/show.html.haml"}}], + "code": "render(partial => \"stat/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })", + "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":34,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"statuses/show","file":"/home/eugr/Projects/mastodon/app/views/statuses/show.html.haml"}}], "location": { "type": "template", - "template": "stream_entries/show" + "template": "statuses/show" }, "user_input": "params[:id]", "confidence": "Weak", diff --git a/db/migrate/20180419235016_add_quote_id_to_statuses.rb b/db/migrate/20180419235016_add_quote_id_to_statuses.rb new file mode 100644 index 0000000000000000000000000000000000000000..d7550b24887964435fdbd97d35582c7bf1e4432a --- /dev/null +++ b/db/migrate/20180419235016_add_quote_id_to_statuses.rb @@ -0,0 +1,5 @@ +class AddQuoteIdToStatuses < ActiveRecord::Migration[5.1] + def change + add_column :statuses, :quote_id, :bigint, null: true, default: nil + end +end diff --git a/db/schema.rb b/db/schema.rb index fc2d3a51188cbeb185e5c7a86d7faabbdb6e374a..0595f4661843fa6394a50f9651b2f359343a6d4b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -687,6 +687,7 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do t.bigint "application_id" t.bigint "in_reply_to_account_id" t.bigint "poll_id" + t.bigint "quote_id" t.datetime "deleted_at" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"