diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 2593ef7da5..875c0de153 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -76,7 +76,8 @@ class Api::V1::StatusesController < Api::BaseController content_type: status_params[:content_type], allowed_mentions: status_params[:allowed_mentions], idempotency: request.headers['Idempotency-Key'], - with_rate_limit: true + with_rate_limit: true, + quote_id: status_params[:quote_id].presence ) render json: @status, serializer: serializer_for_status @@ -159,6 +160,7 @@ class Api::V1::StatusesController < Api::BaseController :visibility, :language, :scheduled_at, + :quote_id, :content_type, allowed_mentions: [], media_ids: [], diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index d70b2a88fd..7517e5cf0d 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -42,6 +42,7 @@ module ContextHelper '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 f0d583bc54..ede95735f7 100644 --- a/app/helpers/formatting_helper.rb +++ b/app/helpers/formatting_helper.rb @@ -19,7 +19,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 8eab532f55..acf400fb1f 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -86,6 +86,9 @@ export const COMPOSE_CHANGE_MEDIA_ORDER = 'COMPOSE_CHANGE_MEDIA_ORDER'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; +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.' }, @@ -136,6 +139,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, @@ -218,6 +240,7 @@ export function submitCompose(routerHistory, overridePrivacy = null) { 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')), media_attributes, sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 5f10c8d889..53c6e2365f 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -59,6 +59,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'); if (normalOldStatus.get('translation')) { normalStatus.translation = normalOldStatus.get('translation'); @@ -72,6 +74,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.emojis); + + const quote_account_emojiMap = makeEmojiMap(status.quote.account.emojis); + 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; + } } if (normalOldStatus) { diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 0915af2004..440e3ea621 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -85,6 +85,7 @@ class Status extends ImmutablePureComponent { rootId: PropTypes.string, onClick: PropTypes.func, onReply: PropTypes.func, + onQuote: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, onBookmark: PropTypes.func, @@ -732,7 +733,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.props.identity; + + if (signedIn) { + this.props.onQuote(this.props.status, this.props.history); + } else { + // TODO(ariadne): Add an interaction modal for quoting specifically. + this.props.onInteractionModal('reply', this.props.status); + } + }; + handleShareClick = () => { navigator.share({ url: this.props.status.get('url'), @@ -217,6 +231,7 @@ class StatusActionBar extends ImmutablePureComponent { let menu = []; let reblogIcon = 'retweet'; + let quoteIcon = 'quote-right'; let replyIcon; let replyIconComponent; let replyTitle; @@ -299,6 +314,7 @@ class StatusActionBar extends ImmutablePureComponent { const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; let reblogTitle, reblogIconComponent; + let quoteTitle, quoteIconComponent; if (status.get('reblogged')) { reblogTitle = intl.formatMessage(messages.cancel_reblog_private); @@ -314,6 +330,19 @@ class StatusActionBar extends ImmutablePureComponent { reblogIconComponent = RepeatDisabledIcon; } + // quotes + if (publicStatus) { + quoteTitle = intl.formatMessage(messages.quote); + quoteIconComponent = FormatQuoteIcon; + } else if (reblogPrivate) { + quoteTitle = intl.formatMessage(messages.reblog_private); + quoteIconComponent = FormatQuoteIcon; + } else { + quoteTitle = intl.formatMessage(messages.cannot_reblog); + quoteIconComponent = FormatQuoteIcon; + } + + const filterButton = this.props.onFilter && ( ); @@ -329,7 +358,10 @@ class StatusActionBar extends ImmutablePureComponent { counter={showReplyCount ? status.get('replies_count') : undefined} obfuscateCount /> + + + diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index 634ab0db29..9d35d48192 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -14,6 +14,7 @@ import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; import LinkIcon from '@/material-icons/400-24px/link.svg?react'; import MovieIcon from '@/material-icons/400-24px/movie.svg?react'; import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react'; +import QuoteIcon from '@/material-icons/400-24px/format_quote-fill.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state'; @@ -361,6 +362,37 @@ class StatusContent extends PureComponent { ); + 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 = ''; @@ -435,6 +467,7 @@ class StatusContent extends 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({ + modalType: 'CONFIRM', + modalProps: { + 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({ statusId: status.get('id') })); diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx index b24f7ced99..76d6b5a4dc 100644 --- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx @@ -4,6 +4,7 @@ import { useSelector } from 'react-redux'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; +import QuoteIcon from '@/material-icons/400-24px/format_quote-fill.svg?react'; import { Avatar } from 'flavours/glitch/components/avatar'; import { DisplayName } from 'flavours/glitch/components/display_name'; import { Icon } from 'flavours/glitch/components/icon'; @@ -11,7 +12,8 @@ import { Permalink } from 'flavours/glitch/components/permalink'; export const ReplyIndicator = () => { const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to'])); - const status = useSelector(state => state.getIn(['statuses', inReplyToId])); + const quoteId = useSelector(state => state.getIn(['compose', 'quote_id'])); + const status = useSelector(state => state.getIn(['statuses', inReplyToId || quoteId])); const account = useSelector(state => state.getIn(['accounts', status?.get('account')])); if (!status) { @@ -22,13 +24,20 @@ export const ReplyIndicator = () => { return (
-
+ {inReplyToId && (
)}
+ {quoteId && ( +