From 61feb28111cd361f4ee720146584337300c65101 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sun, 14 Apr 2024 23:48:39 -0700 Subject: [PATCH] th: quotes Maintained-by: kouhai --- app/controllers/api/v1/statuses_controller.rb | 4 +- app/helpers/context_helper.rb | 1 + app/helpers/formatting_helper.rb | 12 +- .../flavours/glitch/actions/compose.js | 23 ++ .../glitch/actions/importer/normalizer.js | 31 ++ .../flavours/glitch/components/status.jsx | 3 +- .../glitch/components/status_action_bar.jsx | 32 ++ .../glitch/components/status_content.jsx | 35 +++ .../glitch/containers/status_container.js | 23 ++ .../compose/components/compose_form.jsx | 1 + .../compose/components/quote_indicator.jsx | 85 ++++++ .../containers/quote_indicator_container.js | 31 ++ .../features/status/components/action_bar.jsx | 8 + .../status/components/detailed_status.jsx | 2 +- .../containers/detailed_status_container.js | 18 ++ .../flavours/glitch/features/status/index.jsx | 17 ++ .../flavours/glitch/reducers/compose.js | 88 +++--- .../flavours/glitch/styles/components.scss | 53 +++- .../flavours/glitch/styles/contrast/diff.scss | 38 ++- .../glitch/styles/mastodon-light/diff.scss | 280 ++++++++++++++---- .../flavours/glitch/styles/rich_text.scss | 10 + .../400-24px/format_quote-fill.svg | 1 + .../material-icons/400-24px/format_quote.svg | 1 + app/lib/activitypub/activity/create.rb | 25 ++ app/lib/activitypub/tag_manager.rb | 8 +- app/models/status.rb | 25 +- app/models/status_edit.rb | 4 + .../activitypub/note_serializer.rb | 8 +- app/serializers/rest/status_serializer.rb | 10 + app/services/fetch_link_card_service.rb | 2 +- app/services/post_status_service.rb | 2 + app/views/statuses/_detailed_status.html.haml | 4 + app/views/statuses/_quote_status.html.haml | 38 +++ app/views/statuses/_simple_status.html.haml | 5 + ...20221224204906_add_quote_id_to_statuses.rb | 5 + ...24220348_add_index_to_statuses_quote_id.rb | 7 + db/schema.rb | 2 + lib/sanitize_ext/sanitize_config.rb | 1 + 38 files changed, 841 insertions(+), 102 deletions(-) create mode 100644 app/javascript/flavours/glitch/features/compose/components/quote_indicator.jsx create mode 100644 app/javascript/flavours/glitch/features/compose/containers/quote_indicator_container.js create mode 100644 app/javascript/material-icons/400-24px/format_quote-fill.svg create mode 100644 app/javascript/material-icons/400-24px/format_quote.svg create mode 100644 app/views/statuses/_quote_status.html.haml create mode 100644 db/migrate/20221224204906_add_quote_id_to_statuses.rb create mode 100644 db/migrate/20221224220348_add_index_to_statuses_quote_id.rb 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 61245acdba..2b7e9c3003 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.context.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 c28f85eb72..4e20b72c47 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/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx index 6f16abfc7f..ee453ff180 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx @@ -257,6 +257,7 @@ class ComposeForm extends ImmutablePureComponent { return (
+ {/* */} {!withoutNavigation && } diff --git a/app/javascript/flavours/glitch/features/compose/components/quote_indicator.jsx b/app/javascript/flavours/glitch/features/compose/components/quote_indicator.jsx new file mode 100644 index 0000000000..438c022358 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/quote_indicator.jsx @@ -0,0 +1,85 @@ +// Package imports. +import PropTypes from 'prop-types'; + +import { defineMessages, injectIntl } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + + +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; +import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router'; + +import { Avatar } from '../../../components/avatar'; +import { DisplayName } from '../../../components/display_name'; +import { Icon } from '../../../components/icon'; +import { IconButton } from '../../../components/icon_button'; + +// Messages. +const messages = defineMessages({ + cancel: { + defaultMessage: 'Cancel', + id: 'quote_indicator.cancel', + }, +}); + +class QuoteIndicator extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + onCancel: PropTypes.func, + intl: PropTypes.object.isRequired, + ...WithOptionalRouterPropTypes, + }; + + handleClick = () => { + this.props.onCancel(); + }; + + handleAccountClick = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.props.history?.push(`/@${this.props.status.getIn(['account', 'acct'])}`); + } + } + + // Rendering. + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: status.get('contentHtml') }; + + // The result. + return ( + + ); + } + +} + +export default withOptionalRouter(injectIntl(QuoteIndicator)); diff --git a/app/javascript/flavours/glitch/features/compose/containers/quote_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/quote_indicator_container.js new file mode 100644 index 0000000000..f7ba4a4daf --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/quote_indicator_container.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; + +import { cancelQuoteCompose } from 'flavours/glitch/actions/compose'; +import { makeGetStatus } from '../../../selectors'; +import QuoteIndicator from '../components/quote_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = state => { + const statusId = state.getIn(['compose', 'quote_id'], null); + const editing = false; + + return { + status: getStatus(state, { id: 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.jsx b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx index 808712b021..9ac9dc1be2 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx @@ -11,6 +11,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react'; import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import QuoteIcon from '@/material-icons/400-24px/format_quote-fill.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; @@ -38,6 +39,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: 'Favorite' }, @@ -71,6 +73,7 @@ class ActionBar extends PureComponent { onEdit: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onQuote: PropTypes.func.isRequired, onMute: PropTypes.func, onBlock: PropTypes.func, onMuteConversation: PropTypes.func, @@ -97,6 +100,10 @@ class ActionBar extends PureComponent { this.props.onBookmark(this.props.status, e); }; + handleQuoteClick = () => { + this.props.onQuote(this.props.status); + } + handleDeleteClick = () => { this.props.onDelete(this.props.status, this.props.history); }; @@ -250,6 +257,7 @@ class ActionBar extends PureComponent {
+
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx index 2db9fa6d3a..4d55eafac3 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx @@ -221,7 +221,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 7cb9735cfe..8058e04f3d 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 @@ -6,6 +6,7 @@ import { showAlertForError } from '../../../actions/alerts'; import { initBlockModal } from '../../../actions/blocks'; import { replyCompose, + quoteCompose, mentionCompose, directCompose, } from '../../../actions/compose'; @@ -32,6 +33,8 @@ import DetailedStatus from '../components/detailed_status'; 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? Favorites and boosts will be lost, and replies to the original post will be orphaned.' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, @@ -70,6 +73,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({ statusId: status.get('id'), visibility: privacy })); }, diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 00639a667e..2c8fb89d32 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -28,6 +28,7 @@ import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { initBlockModal } from '../../actions/blocks'; import { replyCompose, + quoteCompose, mentionCompose, directCompose, } from '../../actions/compose'; @@ -342,6 +343,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; @@ -760,6 +776,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 211402ab2a..33dade4f1e 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -9,6 +9,8 @@ import { COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, COMPOSE_DIRECT, + COMPOSE_QUOTE, + COMPOSE_QUOTE_CANCEL, COMPOSE_MENTION, COMPOSE_SUBMIT_REQUEST, COMPOSE_SUBMIT_SUCCESS, @@ -80,6 +82,7 @@ const initialState = ImmutableMap({ caretPosition: null, preselectDate: null, in_reply_to: null, + quote_id: null, is_composing: false, is_submitting: false, is_changing_upload: false, @@ -169,6 +172,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,51 @@ 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')); + } + } + }); +}; + const updatePoll = (state, index, value, maxOptions) => state.updateIn(['poll', 'options'], options => { const tmp = options.set(index, value).filterNot(x => x.trim().length === 0); @@ -420,46 +469,17 @@ export default function compose(state = initialState, action) { case COMPOSE_COMPOSING_CHANGE: return state.set('is_composing', action.value); 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()); - - map.update('media_attachments', list => list.filter(media => media.get('unattached'))); - - if (action.status.get('language') && !action.status.has('translation')) { - map.set('language', action.status.get('language')); - } else { - map.set('language', state.get('default_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); // eslint-disable-next-line no-fallthrough -- fall-through to `COMPOSE_RESET` is intended + case COMPOSE_QUOTE_CANCEL: + // eslint-disable-next-line no-fallthrough -- fall-through to `COMPOSE_RESET` is intended 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.scss b/app/javascript/flavours/glitch/styles/components.scss index 5c2a130cb9..9138522c7b 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -1009,6 +1009,50 @@ body > [data-popper-placement] { } } +.quote-indicator, +.reply-indicator { + border-radius: 4px; + margin-bottom: 10px; + background: $ui-primary-color; + padding: 10px; + min-height: 23px; + overflow-y: auto; + flex: 0 2 auto; +} + +.quote-indicator__header, +.reply-indicator__header { + margin-bottom: 5px; + overflow: hidden; +} + +.quote-indicator__cancel, +.reply-indicator__cancel { + float: right; + line-height: 24px; +} + +.quote-indicator__display-name, +.reply-indicator__display-name { + color: $inverted-text-color; + display: block; + max-width: 100%; + line-height: 24px; + overflow: hidden; + text-decoration: none; + + & > .display-name { + line-height: unset; + height: unset; + } +} + +.quote-indicator__display-avatar, +.reply-indicator__display-avatar { + float: left; + margin-inline-end: 5px; +} + .status__content--with-action { cursor: pointer; } @@ -1019,6 +1063,7 @@ body > [data-popper-placement] { .status__content, .edit-indicator__content, +.quote-indicator__content, .reply-indicator__content { position: relative; word-wrap: break-word; @@ -1041,7 +1086,8 @@ body > [data-popper-placement] { } p, - pre { + pre, + blockquote { margin-bottom: 20px; white-space: pre-wrap; unicode-bidi: plaintext; @@ -1419,6 +1465,7 @@ body > [data-popper-placement] { } .focusable { + &:hover, &:focus { outline: 0; background: rgba($ui-highlight-color, 0.05); @@ -2279,7 +2326,9 @@ a .account__avatar { } } -.status__display-name, +a.status__display-name, +.quote-indicator__display-name, +.reply-indicator__display-name, .detailed-status__display-name, a.account__display-name { &:hover .display-name strong { diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss index ae607f484a..321526c9ef 100644 --- a/app/javascript/flavours/glitch/styles/contrast/diff.scss +++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss @@ -1,7 +1,21 @@ +.compose-form { + .compose-form__modifiers { + .compose-form__upload { + &-description { + input { + &::placeholder { + opacity: 1; + } + } + } + } + } +} + .status__content a, -.reply-indicator__content a, -.edit-indicator__content a, .link-footer a, +.quote-indicator__content a, +.reply-indicator__content a, .status__content__read-more-button, .status__content__translate-button { text-decoration: underline; @@ -29,9 +43,7 @@ } } -.status__content a, -.reply-indicator__content a, -.edit-indicator__content a { +.status__content a { color: $highlight-text-color; } @@ -39,10 +51,24 @@ color: $darker-text-color; } -.report-dialog-modal__textarea::placeholder { +.compose-form__poll-wrapper .button.button-secondary, +.compose-form .autosuggest-textarea__textarea::placeholder, +.compose-form .spoiler-input__input::placeholder, +.report-dialog-modal__textarea::placeholder, +.language-dropdown__dropdown__results__item__common-name, +.compose-form .icon-button { color: $inverted-text-color; } +.text-icon-button.active { + color: $ui-highlight-color; +} + +.language-dropdown__dropdown__results__item.active { + background: $ui-highlight-color; + font-weight: 500; +} + .link-button:disabled { cursor: not-allowed; diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index 4040ee0fe0..447abf0fad 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -21,6 +21,25 @@ html { } // Change default background colors of columns +.column > .scrollable, +.getting-started, +.column-inline-form, +.regeneration-indicator { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); + border-top: 0; +} + +.error-column { + border: 1px solid lighten($ui-base-color, 8%); +} + +.column > .scrollable.about { + border-top: 1px solid lighten($ui-base-color, 8%); +} + +.about__meta, +.about__section__title, .interaction-modal { background: $white; border: 1px solid lighten($ui-base-color, 8%); @@ -34,6 +53,29 @@ html { background: lighten($ui-base-color, 12%); } +.filter-form { + background: $white; + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.column-back-button, +.column-header { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 0; + } + + &--slim-button { + top: -50px; + right: 0; + } +} + +.column-header__back-button, +.column-header__button, +.column-header__button.active, .account__header { background: $white; } @@ -45,6 +87,7 @@ html { &:active, &:focus { color: $ui-highlight-color; + background: $white; } } @@ -70,6 +113,25 @@ html { } } +.column-subheading { + background: darken($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + +.getting-started, +.scrollable { + .column-link { + background: $white; + border-bottom: 1px solid lighten($ui-base-color, 8%); + + &:hover, + &:active, + &:focus { + background: $ui-base-color; + } + } +} + .getting-started .navigation-bar { border-top: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%); @@ -79,8 +141,11 @@ html { } } +.compose-form__autosuggest-wrapper, +.poll__option input[type='text'], +.compose-form .spoiler-input__input, +.compose-form__poll-wrapper select, .search__input, -.search__popout, .setting-text, .report-dialog-modal__textarea, .audio-player { @@ -103,6 +168,86 @@ html { border-bottom: 0; } +.compose-form__poll-wrapper select { + background: $simple-background-color + url("data:image/svg+xml;utf8,") + no-repeat right 8px center / auto 16px; +} + +.compose-form__poll-wrapper, +.compose-form__poll-wrapper .poll__footer { + border-top-color: lighten($ui-base-color, 8%); +} + +.notification__filter-bar { + border: 1px solid lighten($ui-base-color, 8%); + border-top: 0; +} + +.compose-form .compose-form__buttons-wrapper { + background: $ui-base-color; + border: 1px solid lighten($ui-base-color, 8%); + border-top: 0; +} + +.drawer__header, +.drawer__inner { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); +} + +.drawer__inner__mastodon { + background: $white + url('data:image/svg+xml;utf8,') + no-repeat bottom / 100% auto; +} + +// Change the colors used in compose-form +.compose-form { + .compose-form__modifiers { + .compose-form__upload__actions .icon-button, + .compose-form__upload__warning .icon-button { + color: lighten($white, 7%); + + &:active, + &:focus, + &:hover { + color: $white; + } + } + } + + .compose-form__buttons-wrapper { + background: darken($ui-base-color, 6%); + } + + .autosuggest-textarea__suggestions { + background: darken($ui-base-color, 6%); + } + + .autosuggest-textarea__suggestions__item { + &:hover, + &:focus, + &:active, + &.selected { + background: lighten($ui-base-color, 4%); + } + } +} + +.emoji-mart-bar { + border-color: lighten($ui-base-color, 4%); + + &:first-child { + background: darken($ui-base-color, 6%); + } +} + +.emoji-mart-search input { + background: rgba($ui-base-color, 0.3); + border-color: $ui-base-color; +} + .upload-progress__backdrop { background: $ui-base-color; } @@ -112,7 +257,13 @@ html { background: lighten($white, 4%); } +.detailed-status, +.detailed-status__action-bar { + background: $white; +} + // 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; @@ -123,11 +274,52 @@ html { } } +// Change the background colors of media and video spoilers +.media-spoiler, +.video-player__spoiler { + background: $ui-base-color; +} + +.privacy-dropdown.active .privacy-dropdown__value.active .icon-button { + color: $white; +} + .account-gallery__item a { background-color: $ui-base-color; } +// Change the colors used in the dropdown menu +.dropdown-menu { + background: $white; + + &__arrow::before { + background-color: $white; + } + + &__item { + color: $darker-text-color; + + &--dangerous { + color: $error-value-color; + } + + a, + button { + background: $white; + } + } +} + // Change the text colors on inverted background +.privacy-dropdown__option.active, +.privacy-dropdown__option:hover, +.privacy-dropdown__option.active .privacy-dropdown__option__content, +.privacy-dropdown__option.active .privacy-dropdown__option__content strong, +.privacy-dropdown__option:hover .privacy-dropdown__option__content, +.privacy-dropdown__option:hover .privacy-dropdown__option__content strong, +.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:active, +.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:focus, +.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:hover, .actions-modal ul li:not(:empty) a.active, .actions-modal ul li:not(:empty) a.active button, .actions-modal ul li:not(:empty) a:active, @@ -136,6 +328,7 @@ html { .actions-modal ul li:not(:empty) a:focus button, .actions-modal ul li:not(:empty) a:hover, .actions-modal ul li:not(:empty) a:hover button, +.language-dropdown__dropdown__results__item.active, .admin-wrapper .sidebar ul .simple-navigation-active-leaf a, .simple_form .block-button, .simple_form .button, @@ -143,6 +336,19 @@ html { color: $white; } +.language-dropdown__dropdown__results__item + .language-dropdown__dropdown__results__item__common-name { + color: lighten($ui-base-color, 8%); +} + +.language-dropdown__dropdown__results__item.active + .language-dropdown__dropdown__results__item__common-name { + color: darken($ui-base-color, 12%); +} + +.dropdown-menu__separator, +.dropdown-menu__item.edited-timestamp__history__item, +.dropdown-menu__container__header, .compare-history-modal .report-modal__target, .report-dialog-modal .poll__option.dialog-option { border-bottom-color: lighten($ui-base-color, 4%); @@ -176,7 +382,10 @@ html { .reactions-bar__item:hover, .reactions-bar__item:focus, -.reactions-bar__item:active { +.reactions-bar__item:active, +.language-dropdown__dropdown__results__item:hover, +.language-dropdown__dropdown__results__item:focus, +.language-dropdown__dropdown__results__item:active { background-color: $ui-base-color; } @@ -214,7 +423,7 @@ html { .column-header__collapsible-inner { background: darken($ui-base-color, 4%); border: 1px solid lighten($ui-base-color, 8%); - border-bottom: 0; + border-top: 0; } .column-settings__hashtags .column-select__option { @@ -264,11 +473,11 @@ html { } .react-toggle-track { - background: $ui-primary-color; + background: $ui-secondary-color; } .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { - background: lighten($ui-primary-color, 10%); + background: darken($ui-secondary-color, 10%); } .react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) @@ -419,7 +628,14 @@ html { } } +.quote-indicator, +.reply-indicator { + background: transparent; + border: 1px solid lighten($ui-base-color, 8%); +} + .status__content, +.quote-indicator__content, .reply-indicator__content { a { color: $highlight-text-color; @@ -440,8 +656,7 @@ html { .directory__tag > div, .card > a, .page-header, -.compose-form, -.compose-form__warning { +.compose-form .compose-form__warning { box-shadow: none; } @@ -513,6 +728,13 @@ html { } } +.status.collapsed .status__content::after { + background: linear-gradient( + rgba(darken($ui-base-color, 13%), 0), + rgba(darken($ui-base-color, 13%), 1) + ); +} + .drawer__inner__mastodon { background: $white url('data:image/svg+xml;utf8,') @@ -522,47 +744,3 @@ html { filter: contrast(75%) brightness(75%) !important; } } - -.compose-form__actions .icon-button.active, -.dropdown-button.active, -.privacy-dropdown__option.active, -.privacy-dropdown__option:focus, -.language-dropdown__dropdown__results__item:focus, -.language-dropdown__dropdown__results__item.active, -.privacy-dropdown__option:focus .privacy-dropdown__option__content, -.privacy-dropdown__option:focus .privacy-dropdown__option__content strong, -.privacy-dropdown__option.active .privacy-dropdown__option__content, -.privacy-dropdown__option.active .privacy-dropdown__option__content strong, -.language-dropdown__dropdown__results__item:focus - .language-dropdown__dropdown__results__item__common-name, -.language-dropdown__dropdown__results__item.active - .language-dropdown__dropdown__results__item__common-name { - color: $white; -} - -.compose-form .spoiler-input__input { - color: lighten($ui-highlight-color, 8%); -} - -.compose-form .autosuggest-textarea__textarea, -.compose-form__highlightable, -.search__input, -.search__popout, -.emoji-mart-search input, -.language-dropdown__dropdown .emoji-mart-search input, -.poll__option input[type='text'] { - background: darken($ui-base-color, 10%); -} - -.inline-follow-suggestions { - background-color: rgba($ui-highlight-color, 0.1); - border-bottom-color: rgba($ui-highlight-color, 0.3); -} - -.inline-follow-suggestions__body__scrollable__card { - background: $white; -} - -.inline-follow-suggestions__body__scroll-button__icon { - color: $white; -} diff --git a/app/javascript/flavours/glitch/styles/rich_text.scss b/app/javascript/flavours/glitch/styles/rich_text.scss index 266a09eb8e..892aca457e 100644 --- a/app/javascript/flavours/glitch/styles/rich_text.scss +++ b/app/javascript/flavours/glitch/styles/rich_text.scss @@ -1,6 +1,8 @@ +.status__quote, .status__content__text, .e-content, .edit-indicator__content, +.quote-indicator__content, .reply-indicator__content { pre, blockquote { @@ -91,3 +93,11 @@ list-style-type: decimal; } } + +.quote-indicator__content, +.reply-indicator__content { + blockquote { + border-inline-start-color: $inverted-text-color; + color: $inverted-text-color; + } +} diff --git a/app/javascript/material-icons/400-24px/format_quote-fill.svg b/app/javascript/material-icons/400-24px/format_quote-fill.svg new file mode 100644 index 0000000000..f4afa3ed17 --- /dev/null +++ b/app/javascript/material-icons/400-24px/format_quote-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/format_quote.svg b/app/javascript/material-icons/400-24px/format_quote.svg new file mode 100644 index 0000000000..c354385ea9 --- /dev/null +++ b/app/javascript/material-icons/400-24px/format_quote.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index c384505232..f091f0d201 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -130,6 +130,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachment_ids: attachment_ids, ordered_media_attachment_ids: attachment_ids, poll: process_poll, + quote: process_quote, } end @@ -430,4 +431,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/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 8643286317..e6d9d607d9 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -83,11 +83,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 a6ea7bb90b..3eaad9bb6a 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -29,6 +29,7 @@ # edited_at :datetime # trendable :boolean # ordered_media_attachment_ids :bigint(8) is an Array +# quote_id :bigint(8) # class Status < ApplicationRecord @@ -66,6 +67,7 @@ class Status < ApplicationRecord with_options class_name: 'Status', optional: true do belongs_to :thread, foreign_key: 'in_reply_to_id', inverse_of: :replies belongs_to :reblog, foreign_key: 'reblog_of_id', inverse_of: :reblogs + belongs_to :quote, foreign_key: 'quote_id', inverse_of: :quoted end has_many :favourites, inverse_of: :status, dependent: :destroy @@ -76,6 +78,7 @@ class Status < ApplicationRecord has_many :mentions, dependent: :destroy, inverse_of: :status has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account' has_many :media_attachments, dependent: :nullify + has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify # The `dependent` option is enabled by the initial `mentions` association declaration has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status # rubocop:disable Rails/HasManyOrHasOneDependent @@ -102,6 +105,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 @@ -178,6 +182,17 @@ class Status < ApplicationRecord account: [:account_stat, user: :role], active_mentions: :account, ], + quote: [ + :application, + :tags, + :media_attachments, + :conversation, + :status_stat, + :preloadable_poll, + preview_cards_status: [:preview_card], + account: [:account_stat, :user], + active_mentions: { account: :account_stat }, + ], thread: :account delegate :domain, to: :account, prefix: true @@ -212,6 +227,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 @@ -284,7 +307,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 e5d7cb46ea..810a2f9b7d 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 52ffaf7170..28122393c2 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? @@ -150,6 +152,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 96bdd600cf..3638e871b1 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -202,3 +202,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 8bc9f912c5..c9018b7d85 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -78,7 +78,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 f67064c9a1..8c38f00ae3 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -31,6 +31,7 @@ class PostStatusService < BaseService # @option [String] :idempotency Optional idempotency key # @option [Boolean] :with_rate_limit # @option [Enumerable] :allowed_mentions Optional array of expected mentioned account IDs, raises `UnexpectedMentionsError` if unexpected accounts end up in mentions + # @option [String] :quote_id # @return [Status] def call(account, options = {}) @account = account @@ -210,6 +211,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 c55dff5d96..39b6d4824b 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -15,7 +15,11 @@ = 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< %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}  diff --git a/app/views/statuses/_quote_status.html.haml b/app/views/statuses/_quote_status.html.haml new file mode 100644 index 0000000000..514e04800c --- /dev/null +++ b/app/views/statuses/_quote_status.html.haml @@ -0,0 +1,38 @@ +.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 noreferrer' do + .status__avatar + %div + - if prefers_autoplay? + = image_tag status.account.avatar_original_url, alt: '', class: 'u-photo account__avatar' + - else + = image_tag status.account.avatar_static_url, alt: '', class: 'u-photo account__avatar' + %span.display-name + %bdi + %strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: prefers_autoplay?) +   + %span.display-name__account + = acct(status.account) + = fa_icon('lock') if status.account.locked? + + .status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }< + + - if status.spoiler_text? + %p< + %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}  + %button.status__content__spoiler-link= t('statuses.show_more') + .e-content{ lang: status.language }< + = prerender_custom_emojis(status_content_format(status), status.emojis) + + - if status.preloadable_poll + = render_poll_component(status) + + - if !status.ordered_media_attachments.empty? + - if status.ordered_media_attachments.first.video? + = render_video_component(status, width: 610, height: 343) + - elsif status.ordered_media_attachments.first.audio? + = render_audio_component(status, width: 610, height: 343) + - else + = render_media_gallery_component(status, height: 343) + - elsif status.preview_card + = render_card_component(status) + diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index 6aa4d23dab..31ad335aa2 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -27,7 +27,12 @@ %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< %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}  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 0000000000..b01f6520a2 --- /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 0000000000..2a51daf883 --- /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 a3c4f0408a..7ca144d605 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1087,6 +1087,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_07_094856) do t.datetime "edited_at", precision: nil 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)" @@ -1094,6 +1095,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_07_094856) 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 5bc6da6ebe..bff81804be 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(' ')