th: quotes

Maintained-by: kouhai <kouhai@treehouse.systems>
main-rebase
Ariadne Conill 2024-04-14 23:48:39 -07:00 committed by kouhai
parent d7d477047e
commit 964945fb29
39 changed files with 551 additions and 51 deletions

View File

@ -69,7 +69,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: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
@ -140,6 +141,7 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:language,
:scheduled_at,
:quote_id,
:content_type,
allowed_mentions: [],
media_ids: [],

View File

@ -33,6 +33,7 @@ module ContextHelper
'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

View File

@ -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
"<span class=\"quote-inline\"><a href=\"#{status.quote.to_log_permalink}\" class=\"status-link unhandled-link\" target=\"_blank\">#{status.quote.to_log_permalink}</a></span>"
end.html_safe # rubocop:disable Rails/OutputSafety
base + after_html
else
base
end
end
def rss_status_content_format(status)

View File

@ -86,6 +86,9 @@ export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
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.' },
@ -142,6 +145,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,
@ -224,6 +246,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')),
media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
@ -278,12 +301,16 @@ export function submitCompose(routerHistory) {
insertIfOnline('direct');
}
dispatch(showAlert({
message: statusId === null ? messages.published : messages.saved,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
// Upstream shows a "post published" message every time you post now. This is highly annoying.
// Instead, only display a message if a draft is saved.
if (statusId !== null) {
dispatch(showAlert({
message: messages.saved,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
}
}).catch(function (error) {
dispatch(submitComposeFail(error));
});

View File

@ -48,6 +48,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');
@ -61,6 +63,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(/<br\s*\/?>/g, '\n').replace(/<\/p><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 = '<p>'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'</p>';
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) {

View File

@ -81,6 +81,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,
@ -724,7 +725,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(
<Card
onOpenMedia={this.handleOpenMedia}

View File

@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote-fill.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
@ -45,6 +46,7 @@ const messages = defineMessages({
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
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' },
@ -77,6 +79,7 @@ class StatusActionBar extends ImmutablePureComponent {
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
@ -117,6 +120,17 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleQuoteClick = () => {
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'),
@ -220,6 +234,7 @@ class StatusActionBar extends ImmutablePureComponent {
let menu = [];
let reblogIcon = 'retweet';
let quoteIcon = 'quote-right';
let replyIcon;
let replyIconComponent;
let replyTitle;
@ -302,6 +317,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);
@ -317,6 +333,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 && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' iconComponent={VisibilityIcon} onClick={this.handleHideClick} />
);
@ -332,7 +361,10 @@ class StatusActionBar extends ImmutablePureComponent {
counter={showReplyCount ? status.get('replies_count') : undefined}
obfuscateCount
/>
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} /* active={status.get('reblogged')} */ title={quoteTitle} icon={quoteIcon} iconComponent={quoteIconComponent} onClick={this.handleQuoteClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />

View File

@ -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 { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
@ -363,6 +364,37 @@ class StatusContent extends PureComponent {
<TranslateButton onClick={this.handleTranslate} translation={status.get('translation')} />
);
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 = (
<div className='status__quote'>
<blockquote>
<bdi>
<span className='quote-display-name'>
<Icon
fixedWidth
aria-hidden='true'
key='icon-quote-right'
icon={QuoteIcon} />
<strong className='display-name__html'>
<a onClick={this.handleAccountClick} href={quoteStatus.getIn(['account', 'url'])} dangerouslySetInnerHTML={quoteStatusDisplayName} />
</strong>
</span>
</bdi>
<div>
<a href={quoteStatus.get('url')} target='_blank' rel='noopener noreferrer' dangerouslySetInnerHTML={quoteStatusContent} />
</div>
</blockquote>
</div>
);
}
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
@ -437,6 +469,7 @@ class StatusContent extends PureComponent {
{mentionsPlaceholder}
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
{quote}
<div
ref={this.setContentsRef}
key={`contents-${tagLinks}`}
@ -462,6 +495,7 @@ class StatusContent extends PureComponent {
onMouseUp={this.handleMouseUp}
tabIndex={0}
>
{quote}
<div
ref={this.setContentsRef}
key={`contents-${tagLinks}-${rewriteMentions}`}
@ -483,6 +517,7 @@ class StatusContent extends PureComponent {
className='status__content'
tabIndex={0}
>
{quote}
<div
ref={this.setContentsRef}
key={`contents-${tagLinks}`}

View File

@ -6,6 +6,7 @@ import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initBoostModal } from 'flavours/glitch/actions/boosts';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from 'flavours/glitch/actions/compose';
@ -52,6 +53,8 @@ const messages = defineMessages({
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
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?' },
unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
@ -114,6 +117,26 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
});
},
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(status));

View File

@ -19,6 +19,7 @@ import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import OptionsContainer from '../containers/options_container';
import PollFormContainer from '../containers/poll_form_container';
import QuoteIndicatorContainer from '../containers/quote_indicator_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
@ -275,6 +276,7 @@ class ComposeForm extends ImmutablePureComponent {
<WarningContainer />
<ReplyIndicatorContainer />
<QuoteIndicatorContainer />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
<AutosuggestInput

View File

@ -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 (
<article className='quote-indicator'>
<header className='quote-indicator__header'>
<div className='quote-indicator__cancel'>
<IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={this.handleClick} inverted />
</div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='quote-indicator__display-name' target='_blank' rel='noopener noreferrer'>
<div className='quote-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} inline />
</a>
</header>
<div className='quote-indicator__content translate' dangerouslySetInnerHTML={content} />
{status.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
)}
</article>
);
}
}
export default withOptionalRouter(injectIntl(QuoteIndicator));

View File

@ -12,6 +12,7 @@ import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { Icon } from '../../../components/icon';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
@ -50,7 +51,9 @@ class ReplyIndicator extends ImmutablePureComponent {
return (
<div className='reply-indicator'>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={this.handleClick} inverted /></div>
<div className='reply-indicator__cancel'>
<IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={this.handleClick} inverted />
</div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' target='_blank' rel='noopener noreferrer'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>

View File

@ -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);

View File

@ -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';
@ -37,6 +38,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' },
@ -74,6 +76,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,
@ -100,6 +103,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);
};
@ -253,6 +260,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={replyIcon} iconComponent={replyIconComponent} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='quote-right-icon' disabled={!publicStatus} title={intl.formatMessage(messages.quote)} icon='quote-right' iconComponent={QuoteIcon} onClick={this.handleQuoteClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>

View File

@ -228,7 +228,7 @@ class DetailedStatus extends ImmutablePureComponent {
);
mediaIcons.push('picture-o');
}
} else if (status.get('card')) {
} else if (!status.get('quote') && status.get('card')) {
media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
mediaIcons.push('link');
}

View File

@ -7,6 +7,7 @@ import { initBlockModal } from '../../../actions/blocks';
import { initBoostModal } from '../../../actions/boosts';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from '../../../actions/compose';
@ -33,6 +34,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' },
@ -71,6 +74,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));
},

View File

@ -28,6 +28,7 @@ import { initBlockModal } from '../../actions/blocks';
import { initBoostModal } from '../../actions/boosts';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@ -346,6 +347,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;
@ -764,6 +780,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}

View File

@ -8,6 +8,8 @@ import {
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_DIRECT,
COMPOSE_QUOTE,
COMPOSE_QUOTE_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
@ -87,6 +89,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,
@ -175,6 +178,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')),
@ -363,6 +367,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'));
}
}
});
};
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
@ -412,46 +461,17 @@ 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());
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);

View File

@ -794,6 +794,7 @@ body > [data-popper-placement] {
}
}
.quote-indicator,
.reply-indicator {
border-radius: 4px;
margin-bottom: 10px;
@ -804,16 +805,19 @@ body > [data-popper-placement] {
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;
@ -828,6 +832,7 @@ body > [data-popper-placement] {
}
}
.quote-indicator__display-avatar,
.reply-indicator__display-avatar {
float: left;
margin-inline-end: 5px;
@ -841,6 +846,7 @@ body > [data-popper-placement] {
clear: both;
}
.quote-indicator__content,
.reply-indicator__content {
position: relative;
font-size: 14px;
@ -853,7 +859,8 @@ body > [data-popper-placement] {
white-space: pre-wrap;
p,
pre {
pre,
blockquote {
margin-bottom: 20px;
white-space: pre-wrap;
@ -1001,6 +1008,7 @@ body > [data-popper-placement] {
}
.focusable {
&:hover,
&:focus {
outline: 0;
background: lighten($ui-base-color, 4%);
@ -1447,6 +1455,7 @@ a .account__avatar {
}
a.status__display-name,
.quote-indicator__display-name,
.reply-indicator__display-name,
.detailed-status__display-name,
.account__display-name {

View File

@ -14,6 +14,7 @@
.status__content a,
.link-footer a,
.quote-indicator__content a,
.reply-indicator__content a,
.status__content__read-more-button,
.status__content__translate-button {

View File

@ -263,6 +263,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;
@ -627,12 +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;

View File

@ -1,5 +1,7 @@
.status__quote,
.status__content__text,
.e-content,
.quote-indicator__content,
.reply-indicator__content {
pre,
blockquote {
@ -91,6 +93,7 @@
}
}
.quote-indicator__content,
.reply-indicator__content {
blockquote {
border-inline-start-color: $inverted-text-color;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m228-240 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T458-480L320-240h-92Zm360 0 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T818-480L680-240h-92Z"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m228-240 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T458-480L320-240h-92Zm360 0 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T818-480L680-240h-92ZM320-500q25 0 42.5-17.5T380-560q0-25-17.5-42.5T320-620q-25 0-42.5 17.5T260-560q0 25 17.5 42.5T320-500Zm360 0q25 0 42.5-17.5T740-560q0-25-17.5-42.5T680-620q-25 0-42.5 17.5T620-560q0 25 17.5 42.5T680-500Zm0-60Zm-360 0Z"/></svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@ -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

View File

@ -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

View File

@ -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
@ -64,6 +65,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
@ -74,6 +76,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
@ -100,6 +103,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
@ -173,6 +177,17 @@ class Status < ApplicationRecord
account: [:account_stat, user: :role],
active_mentions: { account: :account_stat },
],
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: :account_stat }
delegate :domain, to: :account, prefix: true
@ -207,6 +222,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
@ -279,7 +302,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

View File

@ -62,6 +62,10 @@ class StatusEdit < ApplicationRecord
end
end
def quote?
status.quote?
end
def proper
self
end

View File

@ -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

View File

@ -200,3 +200,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

View File

@ -75,7 +75,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

View File

@ -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

View File

@ -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)}&nbsp;

View File

@ -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?)
&nbsp;
%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)}&nbsp;
%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)

View File

@ -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)}&nbsp;

View File

@ -0,0 +1,5 @@
class AddQuoteIdToStatuses < ActiveRecord::Migration[6.1]
def change
add_column :statuses, :quote_id, :bigint, null: true, default: nil
end
end

View File

@ -0,0 +1,7 @@
class AddIndexToStatusesQuoteId < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def change
add_index :statuses, :quote_id, algorithm: :concurrently
end
end

View File

@ -1007,6 +1007,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) 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)"
@ -1014,6 +1015,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) 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

View File

@ -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(' ')