diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index e2e48f633..96c38ff55 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -65,7 +65,8 @@ class Api::V1::StatusesController < Api::BaseController poll: status_params[:poll], content_type: status_params[:content_type], 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 @@ -129,6 +130,7 @@ class Api::V1::StatusesController < Api::BaseController :visibility, :language, :scheduled_at, + :quote_id, :content_type, media_ids: [], poll: [ diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 2f5fecaae..69d3be752 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -24,6 +24,7 @@ module ContextHelper voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, + quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' }, }.freeze def full_context diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb index 448177bec..82cb4a562 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -15,7 +15,17 @@ module FormattingHelper module_function :extract_status_plain_text def status_content_format(status) - html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type) + base = html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type) + + if status.quote? && status.local? + after_html = begin + "#{status.quote.to_log_permalink}" + end.html_safe # rubocop:disable Rails/OutputSafety + + base + after_html + else + base + end end def rss_status_content_format(status) diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 54909b56e..2698ccad9 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -82,6 +82,9 @@ export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; +export const COMPOSE_QUOTE = 'COMPOSE_QUOTE'; +export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL'; + const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, @@ -134,6 +137,25 @@ export function cancelReplyCompose() { }; }; +export function quoteCompose(status, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_QUOTE, + status: status, + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/publish'); + } + }; +}; + +export function cancelQuoteCompose() { + return { + type: COMPOSE_QUOTE_CANCEL, + }; +}; + export function resetCompose() { return { type: COMPOSE_RESET, @@ -187,6 +209,7 @@ export function submitCompose(routerHistory) { status, content_type: getState().getIn(['compose', 'content_type']), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), + quote_id: getState().getIn(['compose', 'quote_id'], null), media_ids: media.map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), spoiler_text: spoilerText, diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 1c9f524e4..567db7eeb 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -74,6 +74,8 @@ export function normalizeStatus(status, normalOldStatus, settings) { 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) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); @@ -83,6 +85,35 @@ export function normalizeStatus(status, normalOldStatus, settings) { normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText); + + 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.quote-inline'), 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 = (quote_spoilerText.length > 0 || normalStatus.quote.sensitive) && autoHideCW(settings, quote_spoilerText); + + // delete the quote link!!!! + let parentDocElem = domParser.parseFromString(normalStatus.contentHtml, 'text/html').documentElement; + Array.from(parentDocElem.querySelectorAll('span.quote-inline'), span => span.remove()); + normalStatus.contentHtml = parentDocElem.children[1].innerHTML; + } } return normalStatus; diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 800832dc8..e89cf246b 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -69,6 +69,7 @@ class Status extends ImmutablePureComponent { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, onReply: PropTypes.func, + onQuote: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, onBookmark: PropTypes.func, @@ -687,7 +688,7 @@ class Status extends ImmutablePureComponent { if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) { background = attachments.getIn([0, 'preview_url']); } - } else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) { + } else if (!status.get('quote') && status.get('card') && settings.get('inline_preview_cards') && !this.props.muted) { media.push( { + const { signedIn } = this.context.identity; + + if (signedIn) { + this.props.onQuote(this.props.status, this.context.router.history); + } else { + // TODO(ariadne): Add an interaction modal for quoting specifically. + this.props.onInteractionModal('reply', this.props.status); + } + } + handleBookmarkClick = (e) => { this.props.onBookmark(this.props.status, e); } @@ -307,6 +320,8 @@ class StatusActionBar extends ImmutablePureComponent { obfuscateCount /> + + {shareButton} diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index c618cedca..9eba060ca 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -275,6 +275,37 @@ export default class StatusContent extends React.PureComponent { 'status__content--with-spoiler': status.get('spoiler_text').length > 0, }); + let quote = ''; + + if (status.get('quote', null) !== null) { + let quoteStatus = status.get('quote'); + let quoteStatusContent = { __html: quoteStatus.get('contentHtml') }; + let quoteStatusAccount = quoteStatus.get('account'); + let quoteStatusDisplayName = { __html: quoteStatusAccount.get('display_name_html') }; + + quote = ( + + ); + } + if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; @@ -340,6 +371,7 @@ export default class StatusContent extends React.PureComponent { {mentionsPlaceholder}
+ {quote}
+ {quote}
+ {quote}
({ }); }, + onQuote (status, router) { + dispatch((_, getState) => { + let state = getState(); + + if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.quoteMessage), + confirm: intl.formatMessage(messages.quoteConfirm), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)), + onConfirm: () => dispatch(quoteCompose(status, router)), + })); + } else { + dispatch(quoteCompose(status, router)); + } + }); + }, + onModalReblog (status, privacy) { if (status.get('reblogged')) { dispatch(unreblog(status)); diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index abdd247a0..b7a9e4a82 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -2,6 +2,7 @@ import React from 'react'; 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 { defineMessages, injectIntl } from 'react-intl'; @@ -309,6 +310,7 @@ class ComposeForm extends ImmutablePureComponent { +
{ + const { onCancel } = this.props; + if (onCancel) { + onCancel(); + } + } + + // Rendering. + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const account = status.get('account'); + const content = status.get('content'); + const attachments = status.get('media_attachments'); + + // The result. + return ( +
+
+ + + {account && ( + + )} +
+
+ {attachments.size > 0 && ( + + )} +
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js index 7ad9e2b64..2dff62db1 100644 --- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js @@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; // Components. import AccountContainer from 'flavours/glitch/containers/account_container'; +import Icon from 'flavours/glitch/components/icon'; import IconButton from 'flavours/glitch/components/icon_button'; import AttachmentList from 'flavours/glitch/components/attachment_list'; @@ -58,6 +59,9 @@ class ReplyIndicator extends ImmutablePureComponent { title={intl.formatMessage(messages.cancel)} inverted /> + {account && ( { + const mapStateToProps = state => { + const statusId = state.getIn(['compose', 'quote_id']); + const editing = false; + + return { + status: state.getIn(['statuses', statusId]), + editing, + }; + }; + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelQuoteCompose()); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator); diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index b6f8a9877..c0aae505a 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -18,6 +18,7 @@ const messages = defineMessages({ reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, @@ -52,6 +53,7 @@ class ActionBar extends React.PureComponent { onReblog: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired, + onQuote: PropTypes.func.isRequired, onMute: PropTypes.func, onMuteConversation: PropTypes.func, onBlock: PropTypes.func, @@ -81,6 +83,10 @@ class ActionBar extends React.PureComponent { this.props.onBookmark(this.props.status, e); } + handleQuoteClick = () => { + this.props.onQuote(this.props.status); + } + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.context.router.history); } @@ -215,6 +221,7 @@ class ActionBar extends React.PureComponent {
+
{shareButton}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 46770930f..b53bf1d4e 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -210,7 +210,7 @@ class DetailedStatus extends ImmutablePureComponent { ); mediaIcons.push('picture-o'); } - } else if (status.get('card')) { + } else if (!status.get('quote') && status.get('card')) { media.push(); mediaIcons.push('link'); } diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js index e5e065987..087316401 100644 --- a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js +++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js @@ -3,6 +3,7 @@ import DetailedStatus from '../components/detailed_status'; import { makeGetStatus } from 'flavours/glitch/selectors'; import { replyCompose, + quoteCompose, mentionCompose, directCompose, } from 'flavours/glitch/actions/compose'; @@ -33,6 +34,8 @@ import { showAlertForError } from 'flavours/glitch/actions/alerts'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, + 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?' }, redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, 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' }, @@ -68,6 +71,21 @@ 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)); + } + }); + }, + onModalReblog (status, privacy) { dispatch(reblog(status, privacy)); }, diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index aaa9c7928..28023b607 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -23,6 +23,7 @@ import { } from 'flavours/glitch/actions/interactions'; import { replyCompose, + quoteCompose, mentionCompose, directCompose, } from 'flavours/glitch/actions/compose'; @@ -321,6 +322,21 @@ class Status extends ImmutablePureComponent { } } + handleQuoteClick = (status) => { + const { dispatch } = this.props; + const { signedIn } = this.context.identity; + + if (signedIn) { + dispatch(quoteCompose(status, this.context.router.history)); + } else { + dispatch(openModal('INTERACTION', { + type: 'reply', + accountId: status.getIn(['account', 'id']), + url: status.get('url'), + })); + } + } + handleModalReblog = (status, privacy) => { const { dispatch } = this.props; @@ -679,6 +695,7 @@ class Status extends ImmutablePureComponent { onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onBookmark={this.handleBookmarkClick} + onQuote={this.handleQuoteClick} onDelete={this.handleDeleteClick} onEdit={this.handleEditClick} onDirect={this.handleDirectClick} diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 1edc70add..5e5b039f9 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -6,6 +6,8 @@ import { COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, COMPOSE_DIRECT, + COMPOSE_QUOTE, + COMPOSE_QUOTE_CANCEL, COMPOSE_MENTION, COMPOSE_SUBMIT_REQUEST, COMPOSE_SUBMIT_SUCCESS, @@ -85,6 +87,7 @@ const initialState = ImmutableMap({ caretPosition: null, preselectDate: null, in_reply_to: null, + quote_id: null, is_submitting: false, is_uploading: false, is_changing_upload: false, @@ -173,6 +176,7 @@ function clearAll(state) { map.set('is_submitting', false); map.set('is_changing_upload', false); map.set('in_reply_to', null); + map.set('quote_id', null); map.update( 'advanced_options', map => map.mergeWith(overwrite, state.get('default_advanced_options')) @@ -358,6 +362,52 @@ const updateSuggestionTags = (state, token) => { }); }; +const updateWithReply = (state, action) => { + // doesn't support QT&reply + const isQuote = action.type === COMPOSE_QUOTE; + const parentStatusId = action.status.get('id'); + + return state.withMutations(map => { + map.set('id', null); + map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.update( + 'advanced_options', + map => map.merge(new ImmutableMap({ do_not_federate: !!action.status.get('local_only') })), + ); + map.set('focusDate', new Date()); + map.set('caretPosition', null); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + + if (action.status.get('spoiler_text').length > 0) { + let spoilerText = action.status.get('spoiler_text'); + if (action.prependCWRe && !spoilerText.match(/^(re|qt)[: ]/i)) { + spoilerText = isQuote ? `QT: ${spoilerText}` : `re: ${spoilerText}`; + } + map.set('spoiler', true); + map.set('spoiler_text', spoilerText); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + + if (isQuote) { + map.set('in_reply_to', null); + map.set('quote_id', parentStatusId); + map.set('text', ''); + } else { + map.set('in_reply_to', parentStatusId); + map.set('quote_id', null); + map.set('text', statusToTextMentions(state, action.status)); + + if (action.status.get('language')) { + map.set('language', action.status.get('language')); + } + } + }); +}; + + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: @@ -407,41 +457,15 @@ export default function compose(state = initialState, action) { return state .set('elefriend', (state.get('elefriend') + 1) % totalElefriends); case COMPOSE_REPLY: - return state.withMutations(map => { - map.set('id', null); - map.set('in_reply_to', action.status.get('id')); - map.set('text', statusToTextMentions(state, action.status)); - map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); - map.update( - 'advanced_options', - map => map.merge(new ImmutableMap({ do_not_federate: !!action.status.get('local_only') })) - ); - map.set('focusDate', new Date()); - map.set('caretPosition', null); - map.set('preselectDate', new Date()); - map.set('idempotencyKey', uuid()); - - if (action.status.get('language')) { - map.set('language', action.status.get('language')); - } - - if (action.status.get('spoiler_text').length > 0) { - let spoiler_text = action.status.get('spoiler_text'); - if (action.prependCWRe && !spoiler_text.match(/^re[: ]/i)) { - spoiler_text = 're: '.concat(spoiler_text); - } - map.set('spoiler', true); - map.set('spoiler_text', spoiler_text); - } else { - map.set('spoiler', false); - map.set('spoiler_text', ''); - } - }); + case COMPOSE_QUOTE: + return updateWithReply(state, action); case COMPOSE_REPLY_CANCEL: state = state.setIn(['advanced_options', 'threaded_mode'], false); + case COMPOSE_QUOTE_CANCEL: case COMPOSE_RESET: return state.withMutations(map => { map.set('in_reply_to', null); + map.set('quote_id', null); if (defaultContentType) map.set('content_type', defaultContentType); map.set('text', ''); map.set('spoiler', false); diff --git a/app/javascript/flavours/glitch/styles/components/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss index 72d3aad1d..00d395ce9 100644 --- a/app/javascript/flavours/glitch/styles/components/compose_form.scss +++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss @@ -123,6 +123,7 @@ } } +.quote-indicator, .reply-indicator { margin: 0 0 10px; border-radius: 4px; @@ -133,6 +134,7 @@ flex: 0 2 auto; } +.quote-indicator__header, .reply-indicator__header { margin-bottom: 5px; overflow: hidden; @@ -140,11 +142,13 @@ & > .account.small { color: $inverted-text-color; } } +.quote-indicator__cancel, .reply-indicator__cancel { float: right; line-height: 24px; } +.quote-indicator__content, .reply-indicator__content { position: relative; margin: 10px 0; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 054110e41..b19c72cbe 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -77,6 +77,11 @@ } } + .status__quote { + padding-bottom: 0.5em; + } + + .status__quote, .status__content__text, .e-content { overflow: hidden; @@ -123,6 +128,11 @@ font-style: italic; } + i[role=img] { + font-style: normal; + padding-right: 0.25em; + } + sub { font-size: smaller; vertical-align: sub; @@ -686,6 +696,7 @@ } a.status__display-name, +.quote-indicator__display-name, .reply-indicator__display-name, .detailed-status__display-name, .account__display-name { diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss index 4fa1a0361..97b7aa9fe 100644 --- a/app/javascript/flavours/glitch/styles/contrast/diff.scss +++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss @@ -14,6 +14,7 @@ .status__content a, .link-footer a, +.quote-indicator__content a, .reply-indicator__content a, .status__content__read-more-button { text-decoration: underline; diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index 6489c2f80..660ea707c 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -257,6 +257,7 @@ html { } // Change the background colors of status__content__spoiler-link +.quote-indicator__content .status__content__spoiler-link, .reply-indicator__content .status__content__spoiler-link, .status__content .status__content__spoiler-link { background: $ui-base-color; @@ -662,6 +663,7 @@ html { } } +.quote-indicator, .reply-indicator { background: transparent; border: 1px solid lighten($ui-base-color, 8%); @@ -673,6 +675,7 @@ html { } .status__content, +.quote-indicator__content, .reply-indicator__content { a { color: $highlight-text-color; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index ebae12973..4fb3e3dc9 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -126,6 +126,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: process_quote, } end end @@ -426,4 +427,28 @@ class ActivityPub::Activity::Create < ActivityPub::Activity poll.reload retry end + + def guess_quote_url + if @object["quoteUri"] && !@object["quoteUri"].empty? + @object["quoteUri"] + elsif @object["quoteUrl"] && !@object["quoteUrl"].empty? + @object["quoteUrl"] + elsif @object["quoteURL"] && !@object["quoteURL"].empty? + @object["quoteURL"] + elsif @object["_misskey_quote"] && !@object["_misskey_quote"].empty? + @object["_misskey_quote"] + else + nil + end + end + + def process_quote + url = guess_quote_url + return nil if url.nil? + + quote = ResolveURLService.new.call(url) + status_from_uri(quote.uri) if quote + rescue + nil + end end diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb index 7f716f862..ef0c6e1f9 100644 --- a/app/lib/activitypub/case_transform.rb +++ b/app/lib/activitypub/case_transform.rb @@ -14,6 +14,8 @@ module ActivityPub::CaseTransform when String camel_lower_cache[value] ||= if value.start_with?('_:') '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower) + elsif value.start_with?('_') + value else value.underscore.camelize(:lower) end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3d6b28ef5..a8e29d204 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -77,11 +77,15 @@ class ActivityPub::TagManager # Unlisted and private statuses go out primarily to the followers collection # Others go out only to the people they mention def to(status) + to = [] + + to << uri_for(status.quote.account) if status.quote? + case status.visibility when 'public' - [COLLECTIONS[:public]] + to << COLLECTIONS[:public] when 'unlisted', 'private' - [account_followers_url(status.account)] + to << account_followers_url(status.account) when 'direct', 'limited' if status.account.silenced? # Only notify followers if the account is locally silenced diff --git a/app/models/status.rb b/app/models/status.rb index 044816be7..c08f0c55c 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -28,6 +28,7 @@ # edited_at :datetime # trendable :boolean # ordered_media_attachment_ids :bigint(8) is an Array +# quote_id :bigint(8) # class Status < ApplicationRecord @@ -61,6 +62,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: :quote, optional: true has_many :favourites, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy @@ -70,6 +72,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, dependent: :nullify has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards @@ -86,6 +89,7 @@ class Status < ApplicationRecord validates :reblog, uniqueness: { scope: :account }, if: :reblog? validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog? validates :content_type, inclusion: { in: %w(text/plain text/markdown text/html) }, allow_nil: true + validates :quote_visibility, inclusion: { in: %w(public unlisted) }, if: :quote? accepts_nested_attributes_for :poll @@ -134,6 +138,17 @@ class Status < ApplicationRecord account: [:account_stat, :user], active_mentions: { account: :account_stat }, ], + quote: [ + :application, + :tags, + :preview_cards, + :media_attachments, + :conversation, + :status_stat, + :preloadable_poll, + account: [:account_stat, :user], + active_mentions: { account: :account_stat }, + ], thread: { account: :account_stat } delegate :domain, to: :account, prefix: true @@ -195,6 +210,14 @@ class Status < ApplicationRecord !reblog_of_id.nil? end + def quote? + !quote_id.nil? && quote + end + + def quote_visibility + quote&.visibility + end + def within_realtime_window? created_at >= REAL_TIME_WINDOW.ago end @@ -259,7 +282,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 ordered_media_attachments diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb index c2330c04f..c4778d908 100644 --- a/app/models/status_edit.rb +++ b/app/models/status_edit.rb @@ -62,6 +62,10 @@ class StatusEdit < ApplicationRecord end end + def quote? + status.quote? + end + def proper self end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index ca067ed9b..dc810be95 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -3,7 +3,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer include FormattingHelper - context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message + context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message, :quote_uri attributes :id, :type, :summary, :in_reply_to, :published, :url, @@ -11,6 +11,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer :atom_uri, :in_reply_to_atom_uri, :conversation + attribute :quote_uri, if: -> { object.quote? } + attribute :content attribute :content_map, if: :language? attribute :updated, if: :edited? @@ -149,6 +151,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end end + def quote_uri + ActivityPub::TagManager.instance.uri_for(object.quote) if object.quote? + end + def local? object.account.local? end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 659c45b83..f33f499a5 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -184,3 +184,13 @@ class REST::StatusSerializer < ActiveModel::Serializer end end end + +class REST::QuoteStatusSerializer < REST::StatusSerializer + attribute :quote do + nil + end +end + +class REST::StatusSerializer < ActiveModel::Serializer + belongs_to :quote, serializer: REST::QuoteStatusSerializer +end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index e5b5b730e..e5c044892 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -74,7 +74,7 @@ class FetchLinkCardService < BaseService @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize } else document = Nokogiri::HTML(@status.text) - links = document.css('a') + links = document.css(':not(.quote-inline) > a') links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize) end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 36592a531..2e52aec66 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -21,6 +21,7 @@ class PostStatusService < BaseService # @option [Doorkeeper::Application] :application # @option [String] :idempotency Optional idempotency key # @option [Boolean] :with_rate_limit + # @option [String] :quote_id # @return [Status] def call(account, options = {}) @account = account @@ -179,6 +180,7 @@ class PostStatusService < BaseService application: @options[:application], content_type: @options[:content_type] || @account.user&.setting_default_content_type, rate_limit: @options[:with_rate_limit], + 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 bf498e33d..f824752f7 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -15,6 +15,10 @@ = account_action_button(status.account) + - if status.quote? + = render partial: "statuses/quote_status", locals: {status: status.quote} + + .status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }< - if status.spoiler_text? %p< diff --git a/app/views/statuses/_quote_status.html.haml b/app/views/statuses/_quote_status.html.haml new file mode 100644 index 000000000..6fdaa9a84 --- /dev/null +++ b/app/views/statuses/_quote_status.html.haml @@ -0,0 +1,35 @@ +.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) } + = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener' do + .status__avatar + %div + = image_tag status.account.avatar_static_url, width: 18, height: 18, alt: '', class: 'u-photo account__avatar' + %span.display-name + %bdi + %strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true) +   + %span.display-name__account + = acct(status.account) + = fa_icon('lock') if status.account.locked? + + .status__content.emojify< + - if status.spoiler_text? + %p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }< + %span.p-summary> #{Formatter.instance.format_spoiler(status)}  + %button.status__content__spoiler-link= t('statuses.show_more') + .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}" } + = Formatter.instance.format_in_quote(status, custom_emojify: true) + + - if !status.media_attachments.empty? + - if status.media_attachments.first.video? + - video = status.media_attachments.first + = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description, quote: true do + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } + - elsif status.media_attachments.first.audio? + - audio = status.media_attachments.first + = react_component :audio, src: audio.file.url(:original), height: 60, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } + - else + = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_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 do + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } + - elsif status.preview_card + = react_component :card, maxDescription: 10, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index ecbabf34c..78d16a6a9 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -27,6 +27,10 @@ %span.display-name__account = acct(status.account) = fa_icon('lock') if status.account.locked? + + - if status.quote? + = render partial: "statuses/quote_status", locals: {status: status.quote} + .status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }< - if status.spoiler_text? %p< diff --git a/db/migrate/20221224204906_add_quote_id_to_statuses.rb b/db/migrate/20221224204906_add_quote_id_to_statuses.rb new file mode 100644 index 000000000..b01f6520a --- /dev/null +++ b/db/migrate/20221224204906_add_quote_id_to_statuses.rb @@ -0,0 +1,5 @@ +class AddQuoteIdToStatuses < ActiveRecord::Migration[6.1] + def change + add_column :statuses, :quote_id, :bigint, null: true, default: nil + end +end diff --git a/db/migrate/20221224220348_add_index_to_statuses_quote_id.rb b/db/migrate/20221224220348_add_index_to_statuses_quote_id.rb new file mode 100644 index 000000000..2a51daf88 --- /dev/null +++ b/db/migrate/20221224220348_add_index_to_statuses_quote_id.rb @@ -0,0 +1,7 @@ +class AddIndexToStatusesQuoteId < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :statuses, :quote_id, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 0bc7a7460..2ecb1c21c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -943,6 +943,7 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) do t.datetime "edited_at" t.boolean "trendable" t.bigint "ordered_media_attachment_ids", array: true + t.bigint "quote_id" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["account_id"], name: "index_statuses_on_account_id" t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" @@ -950,6 +951,7 @@ ActiveRecord::Schema.define(version: 2022_11_04_133904) do t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id", where: "(in_reply_to_account_id IS NOT NULL)" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", where: "(in_reply_to_id IS NOT NULL)" + t.index ["quote_id"], name: "index_statuses_on_quote_id" t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id" t.index ["uri"], name: "index_statuses_on_uri", unique: true, opclass: :text_pattern_ops, where: "(uri IS NOT NULL)" end diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb index 946543868..be4781870 100644 --- a/lib/sanitize_ext/sanitize_config.rb +++ b/lib/sanitize_ext/sanitize_config.rb @@ -31,6 +31,7 @@ class Sanitize next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes next true if /^(mention|hashtag)$/.match?(e) # semantic classes next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes + next true if /^quote-inline$/.match?(e) # quote inline classes end node['class'] = class_list.join(' ') diff --git a/quote-scss.diff b/quote-scss.diff new file mode 100644 index 000000000..0cbb8345d --- /dev/null +++ b/quote-scss.diff @@ -0,0 +1,110 @@ +diff --git a/app/javascript/flavours/glitch/styles/components/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss +index 72d3aad1d..00d395ce9 100644 +--- a/app/javascript/flavours/glitch/styles/components/compose_form.scss ++++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss +@@ -123,6 +123,7 @@ + } + } + ++.quote-indicator, + .reply-indicator { + margin: 0 0 10px; + border-radius: 4px; +@@ -133,6 +134,7 @@ + flex: 0 2 auto; + } + ++.quote-indicator__header, + .reply-indicator__header { + margin-bottom: 5px; + overflow: hidden; +@@ -140,11 +142,13 @@ + & > .account.small { color: $inverted-text-color; } + } + ++.quote-indicator__cancel, + .reply-indicator__cancel { + float: right; + line-height: 24px; + } + ++.quote-indicator__content, + .reply-indicator__content { + position: relative; + margin: 10px 0; +diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss +index 054110e41..b19c72cbe 100644 +--- a/app/javascript/flavours/glitch/styles/components/status.scss ++++ b/app/javascript/flavours/glitch/styles/components/status.scss +@@ -77,6 +77,11 @@ + } + } + ++ .status__quote { ++ padding-bottom: 0.5em; ++ } ++ ++ .status__quote, + .status__content__text, + .e-content { + overflow: hidden; +@@ -123,6 +128,11 @@ + font-style: italic; + } + ++ i[role=img] { ++ font-style: normal; ++ padding-right: 0.25em; ++ } ++ + sub { + font-size: smaller; + vertical-align: sub; +@@ -686,6 +696,7 @@ + } + + a.status__display-name, ++.quote-indicator__display-name, + .reply-indicator__display-name, + .detailed-status__display-name, + .account__display-name { +diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss +index 4fa1a0361..97b7aa9fe 100644 +--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss ++++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss +@@ -14,6 +14,7 @@ + + .status__content a, + .link-footer a, ++.quote-indicator__content a, + .reply-indicator__content a, + .status__content__read-more-button { + text-decoration: underline; +diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +index 6489c2f80..660ea707c 100644 +--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss ++++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +@@ -257,6 +257,7 @@ html { + } + + // Change the background colors of status__content__spoiler-link ++.quote-indicator__content .status__content__spoiler-link, + .reply-indicator__content .status__content__spoiler-link, + .status__content .status__content__spoiler-link { + background: $ui-base-color; +@@ -662,6 +663,7 @@ html { + } + } + ++.quote-indicator, + .reply-indicator { + background: transparent; + border: 1px solid lighten($ui-base-color, 8%); +@@ -673,6 +675,7 @@ html { + } + + .status__content, ++.quote-indicator__content, + .reply-indicator__content { + a { + color: $highlight-text-color;