diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 106fc8224..1e23cf300 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -46,7 +46,8 @@ class Api::V1::StatusesController < Api::BaseController
application: doorkeeper_token.application,
poll: status_params[:poll],
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
end
@@ -85,6 +86,7 @@ class Api::V1::StatusesController < Api::BaseController
:spoiler_text,
:visibility,
:scheduled_at,
+ :quote_id,
media_ids: [],
poll: [
:multiple,
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 26e7bfab2..fb4d26cf2 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -102,16 +102,14 @@ export function cancelReplyCompose() {
};
};
-export function quoteCompose(status, router) {
+export function quoteCompose(status, routerHistory) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_QUOTE,
status: status,
});
- if (!getState().getIn(['compose', 'mounted'])) {
- router.push('/statuses/new');
- }
+ ensureComposeIsVisible(getState, routerHistory);
};
};
@@ -151,22 +149,13 @@ export function directCompose(account, routerHistory) {
export function submitCompose(routerHistory) {
return function (dispatch, getState) {
- let status = getState().getIn(['compose', 'text'], '');
+ const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
- const quoteId = getState().getIn(['compose', 'quote_from'], null);
if ((!status || !status.length) && media.size === 0) {
return;
}
- if (quoteId) {
- status = [
- status,
- "~~~~~~~~~~",
- `[${quoteId}][${getState().getIn(['compose', 'quote_from_uri'], null)}]`
- ].join("\n");
- }
-
dispatch(submitComposeRequest());
api(getState).post('/api/v1/statuses', {
@@ -177,6 +166,7 @@ export function submitCompose(routerHistory) {
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
+ quote_id: getState().getIn(['compose', 'quote_from'], null),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 5002292b9..d274a0226 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -62,6 +62,8 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
normalStatus.hidden = normalOldStatus.get('hidden');
+ normalStatus.quote = normalOldStatus.get('quote');
+ normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
} else {
// If the status has a CW but no contents, treat the CW as if it were the
// status' contents, to avoid having a CW toggle with seemingly no effect.
@@ -78,6 +80,30 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
+
+ if (status.quote && status.quote.id) {
+ const quote_spoilerText = status.quote.spoiler_text || '';
+ const quote_searchContent = [quote_spoilerText, status.quote.content].join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n');
+
+ const quote_emojiMap = makeEmojiMap(normalStatus.quote);
+
+ const quote_account_emojiMap = makeEmojiMap(status.quote.account);
+ const displayName = normalStatus.quote.account.display_name.length === 0 ? normalStatus.quote.account.username : normalStatus.quote.account.display_name;
+ normalStatus.quote.account.display_name_html = emojify(escapeTextContentForBrowser(displayName), quote_account_emojiMap);
+ normalStatus.quote.search_index = domParser.parseFromString(quote_searchContent, 'text/html').documentElement.textContent;
+ let docElem = domParser.parseFromString(normalStatus.quote.content, 'text/html').documentElement;
+ Array.from(docElem.querySelectorAll('span.invisible'), span => span.remove());
+ Array.from(docElem.querySelectorAll('p,br'), line => {
+ let parentNode = line.parentNode;
+ if (line.nextSibling) {
+ parentNode.insertBefore(document.createTextNode(' '), line.nextSibling);
+ }
+ });
+ let _contentHtml = docElem.textContent;
+ normalStatus.quote.contentHtml = '
'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'
';
+ normalStatus.quote.spoilerHtml = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap);
+ normalStatus.quote_hidden = expandSpoilers ? false : quote_spoilerText.length > 0 || normalStatus.quote.sensitive;
+ }
}
return normalStatus;
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 3fc7c0702..0c520a796 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -30,6 +30,9 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
export const REDRAFT = 'REDRAFT';
+export const QUOTE_REVEAL = 'QUOTE_REVEAL';
+export const QUOTE_HIDE = 'QUOTE_HIDE';
+
export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
@@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
isCollapsed,
};
}
+
+export function hideQuote(ids) {
+ if (!Array.isArray(ids)) {
+ ids = [ids];
+ }
+
+ return {
+ type: QUOTE_HIDE,
+ ids,
+ };
+};
+
+export function revealQuote(ids) {
+ if (!Array.isArray(ids)) {
+ ids = [ids];
+ }
+
+ return {
+ type: QUOTE_REVEAL,
+ ids,
+ };
+};
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 2e7ce2e60..5c303a1a0 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
+ quote: PropTypes.bool,
};
static defaultProps = {
standalone: false,
+ quote: false,
};
state = {
@@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent {
}
render () {
- const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
+ const { media, intl, sensitive, height, defaultWidth, standalone, autoplay, quote } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@@ -332,6 +334,10 @@ class MediaGallery extends React.PureComponent {
const size = media.take(4).size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
+ if (quote && style.height) {
+ style.height /= 2;
+ }
+
if (standalone && this.isFullSizeEligible()) {
children = ;
} else {
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 295e83f58..3019500ec 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -85,6 +85,7 @@ class Status extends ImmutablePureComponent {
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
+ onQuoteToggleHidden: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
@@ -101,6 +102,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
+ contextType: PropTypes.string,
};
// Avoid checking props that are functions (and whose equality will always
@@ -116,6 +118,7 @@ class Status extends ImmutablePureComponent {
state = {
showMedia: defaultMediaVisibility(this.props.status),
+ showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
statusId: undefined,
};
@@ -123,6 +126,7 @@ class Status extends ImmutablePureComponent {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
statusId: nextProps.status.get('id'),
};
} else {
@@ -134,6 +138,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
handleClick = () => {
if (this.props.onClick) {
this.props.onClick();
@@ -164,6 +172,15 @@ class Status extends ImmutablePureComponent {
}
}
+ handleQuoteClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
+ }
+
handleAccountClick = (e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
@@ -180,6 +197,10 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}
+ handleExpandedQuoteToggle = () => {
+ this.props.onQuoteToggleHidden(this._properStatus());
+ };
+
renderLoadingMediaGallery () {
return ;
}
@@ -279,11 +300,17 @@ class Status extends ImmutablePureComponent {
this.node = c;
}
+ _properQuoteStatus () {
+ const { status } = this.props;
+
+ return status.get('quote');
+ }
+
render () {
let media = null;
- let statusAvatar, prepend, rebloggedByText;
+ let statusAvatar, prepend, rebloggedByText, unlistedQuoteText;
- const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props;
+ const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType } = this.props;
let { status, account, ...other } = this.props;
@@ -459,6 +486,106 @@ class Status extends ImmutablePureComponent {
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
+ let quote = null;
+ if (status.get('quote', null) !== null) {
+ let quote_status = status.get('quote');
+
+ let quote_media = null;
+ if (quote_status.get('media_attachments').size > 0) {
+ if (this.props.muted) {
+ quote_media = (
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ } else {
+ quote_media = (
+
+ {Component => (
+
+ )}
+
+ );
+ }
+ }
+
+ if (quote_status.get('visibility') === 'unlisted' && contextType !== 'home') {
+ unlistedQuoteText = intl.formatMessage({ id: 'status.unlisted_quote', defaultMessage: 'Unlisted quote' });
+ quote = (
+
+
+
+
+
+ );
+ } else {
+ quote = (
+
+ );
+ }
+ }
+
return (
@@ -483,6 +610,7 @@ class Status extends ImmutablePureComponent {
+ {quote}
{media}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 5cf7ca802..70081f45f 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -24,6 +24,7 @@ export default class StatusContent extends React.PureComponent {
onClick: PropTypes.func,
collapsable: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
+ quote: PropTypes.bool,
};
state = {
@@ -38,8 +39,6 @@ export default class StatusContent extends React.PureComponent {
}
const links = node.querySelectorAll('a');
- const QuoteUrlFormat = /(?:https?|ftp):\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+\/users\/[\w-_]+(\/statuses\/\w+)/;
- const quote = node.innerText.match(new RegExp(`\\[(\\w+)\\]\\[${QuoteUrlFormat.source}\\]`));
for (var i = 0; i < links.length; ++i) {
let link = links[i];
@@ -48,12 +47,6 @@ export default class StatusContent extends React.PureComponent {
}
link.classList.add('status-link');
- if (quote) {
- if (link.href.match(QuoteUrlFormat)) {
- link.addEventListener('click', this.onQuoteClick.bind(this, quote[1]), false);
- }
- }
-
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
@@ -185,7 +178,7 @@ export default class StatusContent extends React.PureComponent {
}
render () {
- const { status } = this.props;
+ const { status, quote } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');
@@ -238,7 +231,7 @@ export default class StatusContent extends React.PureComponent {
- {!hidden && !!status.get('poll') &&
}
+ {!quote && !hidden && !!status.get('poll') &&
}
{renderViewThread && showThreadButton}
@@ -248,7 +241,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') &&
}
+ {!quote && !!status.get('poll') &&
}
{renderViewThread && showThreadButton}
,
@@ -264,7 +257,7 @@ export default class StatusContent extends React.PureComponent {
- {!!status.get('poll') &&
}
+ {!quote && !!status.get('poll') &&
}
{renderViewThread && showThreadButton}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 43acf597d..8e0730625 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -25,6 +25,8 @@ import {
hideStatus,
revealStatus,
toggleStatusCollapse,
+ hideQuote,
+ revealQuote,
} from '../actions/statuses';
import {
unmuteAccount,
@@ -220,6 +222,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
},
+ onQuoteToggleHidden (status) {
+ if (status.get('quote_hidden')) {
+ dispatch(revealQuote(status.get('id')));
+ } else {
+ dispatch(hideQuote(status.get('id')));
+ }
+ },
+
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/features/compose/components/quote_indicator.js b/app/javascript/mastodon/features/compose/components/quote_indicator.js
index 515c79fca..a48289de2 100644
--- a/app/javascript/mastodon/features/compose/components/quote_indicator.js
+++ b/app/javascript/mastodon/features/compose/components/quote_indicator.js
@@ -7,13 +7,14 @@ import DisplayName from '../../../components/display_name';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { isRtl } from '../../../rtl';
+import AttachmentList from 'mastodon/components/attachment_list';
const messages = defineMessages({
cancel: { id: 'quote_indicator.cancel', defaultMessage: 'Cancel' },
});
-@injectIntl
-export default class QuoteIndicator extends ImmutablePureComponent {
+export default @injectIntl
+class QuoteIndicator extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@@ -30,7 +31,7 @@ export default class QuoteIndicator extends ImmutablePureComponent {
}
handleAccountClick = (e) => {
- if (e.button === 0) {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
@@ -60,6 +61,13 @@ export default class QuoteIndicator extends ImmutablePureComponent {
+
+ {status.get('media_attachments').size > 0 && (
+
+ )}
);
}
diff --git a/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
index eb67f3939..8a3ad4959 100644
--- a/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
+++ b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
@@ -7,7 +7,7 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => ({
- status: getStatus(state, state.getIn(['compose', 'quote_from'])),
+ status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }),
});
return mapStateToProps;
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 1da4b52a4..013dfed9a 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -283,7 +283,7 @@ class ActionBar extends React.PureComponent {
-
+
{shareButton}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 317163648..f4d95b4cb 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -72,6 +72,7 @@ export default class Card extends React.PureComponent {
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
+ quote: PropTypes.bool,
};
static defaultProps = {
@@ -188,7 +189,7 @@ export default class Card extends React.PureComponent {
}
render () {
- const { card, maxDescription, compact } = this.props;
+ const { card, maxDescription, compact, quote } = this.props;
const { width, embedded, revealed } = this.state;
if (card === null) {
@@ -201,7 +202,11 @@ export default class Card extends React.PureComponent {
const className = classnames('status-card', { horizontal, compact, interactive });
const title = interactive ? {card.get('title')} : {card.get('title')};
const ratio = card.get('width') / card.get('height');
- const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+ let height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+ if (quote && height) {
+ height /= 2;
+ }
const description = (
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 043a749ed..ae90a622d 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -46,6 +46,9 @@ class DetailedStatus extends ImmutablePureComponent {
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
+ onQuoteToggleHidden: PropTypes.func.isRequired,
+ showQuoteMedia: PropTypes.bool,
+ onToggleQuoteMediaVisibility: PropTypes.func,
};
state = {
@@ -102,6 +105,19 @@ class DetailedStatus extends ImmutablePureComponent {
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
+ handleExpandedQuoteToggle = () => {
+ this.props.onQuoteToggleHidden(this.props.status);
+ }
+
+ handleQuoteClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
+ }
+
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };
@@ -121,6 +137,73 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
+ let quote = null;
+ if (status.get('quote', null) !== null) {
+ let quote_status = status.get('quote');
+
+ let quote_media = null;
+ if (quote_status.get('media_attachments').size > 0) {
+
+ if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ );
+ } else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ const attachment = quote_status.getIn(['media_attachments', 0]);
+
+ quote_media = (
+
+ );
+ } else {
+ quote_media = (
+
+ );
+ }
+ }
+
+ quote = (
+
+
+
+
+
+
+
+ {quote_media}
+
+ );
+ }
+
if (pictureInPicture.get('inUse')) {
media =
;
} else if (status.get('media_attachments').size > 0) {
@@ -247,6 +330,7 @@ class DetailedStatus extends ImmutablePureComponent {
+ {quote}
{media}
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 0cd25955b..d25905d8e 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -32,6 +32,8 @@ import {
deleteStatus,
hideStatus,
revealStatus,
+ hideQuote,
+ revealQuote,
} from '../../actions/statuses';
import {
unblockAccount,
@@ -181,6 +183,7 @@ class Status extends ImmutablePureComponent {
state = {
fullscreen: false,
showMedia: defaultMediaVisibility(this.props.status),
+ showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
loadedStatusId: undefined,
};
@@ -199,7 +202,8 @@ class Status extends ImmutablePureComponent {
}
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
- this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
+ this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id'),
+ showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)) });
}
}
@@ -207,6 +211,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
+ handleToggleQuoteMediaVisibility = () => {
+ this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
+ }
+
handleFavouriteClick = (status) => {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
@@ -328,6 +336,14 @@ class Status extends ImmutablePureComponent {
}
}
+ handleQuoteToggleHidden = (status) => {
+ if (status.get('quote_hidden')) {
+ this.props.dispatch(revealQuote(status.get('id')));
+ } else {
+ this.props.dispatch(hideQuote(status.get('id')));
+ }
+ }
+
handleToggleAll = () => {
const { status, ancestorsIds, descendantsIds } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
@@ -562,6 +578,9 @@ class Status extends ImmutablePureComponent {
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
pictureInPicture={pictureInPicture}
+ onQuoteToggleHidden={this.handleQuoteToggleHidden}
+ showQuoteMedia={this.state.showQuoteMedia}
+ onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
/>
{
});
};
+const rejectQuoteAltText = html => {
+ const fragment = domParser.parseFromString(html, 'text/html').documentElement;
+
+ const quote_inline = fragment.querySelector('span.quote-inline');
+ if (quote_inline) {
+ quote_inline.remove();
+ }
+
+ return fragment.innerHTML;
+};
+
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
@@ -298,7 +309,7 @@ export default function compose(state = initialState, action) {
return state.withMutations(map => {
map.set('in_reply_to', action.status.get('id'));
map.set('quote_from', null);
- map.set('quote_from_uri', null);
+ map.set('quote_from_url', null);
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('focusDate', new Date());
@@ -318,12 +329,20 @@ export default function compose(state = initialState, action) {
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('quote_from', action.status.get('id'));
- map.set('quote_from_uri', action.status.get('uri'));
+ map.set('quote_from_url', action.status.get('url'));
map.set('text', '');
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('focusDate', new Date());
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
+
+ if (action.status.get('spoiler_text').length > 0) {
+ map.set('spoiler', true);
+ map.set('spoiler_text', action.status.get('spoiler_text'));
+ } else {
+ map.set('spoiler', false);
+ map.set('spoiler_text', '');
+ }
});
case COMPOSE_REPLY_CANCEL:
case COMPOSE_QUOTE_CANCEL:
@@ -331,7 +350,7 @@ export default function compose(state = initialState, action) {
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('quote_from', null);
- map.set('quote_from_uri', null);
+ map.set('quote_from_url', null);
map.set('text', '');
map.set('spoiler', false);
map.set('spoiler_text', '');
@@ -420,8 +439,10 @@ export default function compose(state = initialState, action) {
}));
case REDRAFT:
return state.withMutations(map => {
- map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
+ map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status))));
map.set('in_reply_to', action.status.get('in_reply_to_id'));
+ map.set('quote_from', action.status.getIn(['quote', 'id']));
+ map.set('quote_from_url', action.status.getIn(['quote', 'url']));
map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date());
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 875b2d92b..f139a124b 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -9,6 +9,7 @@ import {
COMPOSE_MENTION,
COMPOSE_REPLY,
COMPOSE_DIRECT,
+ COMPOSE_QUOTE,
} from '../actions/compose';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
@@ -36,6 +37,7 @@ export default function search(state = initialState, action) {
case COMPOSE_REPLY:
case COMPOSE_MENTION:
case COMPOSE_DIRECT:
+ case COMPOSE_QUOTE:
return state.set('hidden', true);
case SEARCH_FETCH_SUCCESS:
return state.set('results', ImmutableMap({
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 53dec9585..7f656ba90 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -13,6 +13,8 @@ import {
STATUS_REVEAL,
STATUS_HIDE,
STATUS_COLLAPSE,
+ QUOTE_REVEAL,
+ QUOTE_HIDE,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
@@ -75,6 +77,14 @@ export default function statuses(state = initialState, action) {
});
case STATUS_COLLAPSE:
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
+ case QUOTE_REVEAL:
+ return state.withMutations(map => {
+ action.ids.forEach(id => map.setIn([id, 'quote_hidden'], false));
+ });
+ case QUOTE_HIDE:
+ return state.withMutations(map => {
+ action.ids.forEach(id => map.setIn([id, 'quote_hidden'], true));
+ });
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
default:
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 8c5c15b8f..f6acf706c 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -291,6 +291,29 @@ function main() {
}
});
});
+
+ delegate(document, '.quote-status', 'click', ({ target }) => {
+ if (target.closest('.status__content__spoiler-link') ||
+ target.closest('.media-gallery') ||
+ target.closest('.video-player') ||
+ target.closest('.audio-player')) {
+ return false;
+ }
+
+ let url = target.closest('.quote-status').getAttribute('dataurl');
+ if (target.closest('.status__display-name')) {
+ url = target.closest('.status__display-name').getAttribute('href');
+ } else if (target.closest('.status-card')) {
+ url = target.closest('.status-card').getAttribute('href');
+ }
+
+ if (window.location.hostname === url.split('/')[2].split(':')[0]) {
+ window.location.href = url;
+ } else {
+ window.open(url, 'blank');
+ }
+ return false;
+ });
}
loadPolyfills()
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 0828ff07c..c329c3206 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1009,6 +1009,70 @@
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
+.quote-inline {
+ display: none;
+}
+
+.quote-status {
+ border: solid 1px $ui-base-lighter-color;
+ border-radius: 4px;
+ padding: 5px;
+ margin-top: 8px;
+ position: relative;
+
+ & > .unlisted-quote {
+ color: $dark-text-color;
+ font-weight: 500;
+
+ & > button {
+ color: $dark-text-color;
+ font-size: 100%;
+ background-color: transparent;
+ border: 0;
+ cursor: pointer;
+ outline: none;
+ padding: 0;
+ appearance: none;
+ }
+ }
+
+ .status__avatar,
+ .detailed-status__display-avatar {
+ height: 18px;
+ width: 18px;
+ position: absolute;
+ top: 5px !important;
+ left: 5px !important;
+ cursor: pointer;
+
+ & > div {
+ width: 18px;
+ height: 18px;
+ }
+ }
+
+ .display-name__account {
+ color: $ui-base-lighter-color;
+ }
+
+ .display-name {
+ padding-left: 20px;
+ }
+
+ .detailed-status__display-name {
+ margin-bottom: 0px;
+
+ strong,
+ span {
+ display: inline;
+ }
+ }
+}
+
+.muted .quote-status .display-name {
+ color: $ui-base-lighter-color;
+}
+
.status__prepend-icon-wrapper {
left: -26px;
position: absolute;
diff --git a/app/javascript/styles/mastodon/statuses.scss b/app/javascript/styles/mastodon/statuses.scss
index 078714325..ad23efae8 100644
--- a/app/javascript/styles/mastodon/statuses.scss
+++ b/app/javascript/styles/mastodon/statuses.scss
@@ -63,6 +63,28 @@
}
}
+ .status.quote-status {
+ border: solid 1px $ui-base-lighter-color;
+ border-radius: 4px;
+ padding: 5px;
+ margin-top: 15px;
+ cursor: pointer;
+ width: 100%;
+
+ .status__avatar {
+ height: 18px;
+ width: 18px;
+ position: absolute;
+ top: 5px;
+ left: 5px;
+
+ & > div {
+ width: 18px;
+ height: 18px;
+ }
+ }
+ }
+
@media screen and (max-width: 740px) {
.detailed-status,
.status,
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 9a2960507..8c937cd1f 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -112,6 +112,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(4).map(&:id),
poll: process_poll,
+ quote: quote_from_url(@object['quoteUrl']),
}
end
end
@@ -500,4 +501,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
poll.reload
retry
end
+
+ def quote_from_url(url)
+ return nil if url.nil?
+
+ quote = ResolveURLService.new.call(url)
+ status_from_uri(quote.uri) if quote
+ end
end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 2d6b87659..e6f8e4d37 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -24,6 +24,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
+ quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
}.freeze
def self.default_key_transform
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index fd6537526..d94e01c07 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -38,11 +38,24 @@ class Formatter
html = encode_and_link_urls(html, linkable_accounts)
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
html = simple_format(html, {}, sanitize: false)
+ html = quotify(html, status) if status.quote? && !options[:escape_quotify]
html = html.delete("\n")
html.html_safe # rubocop:disable Rails/OutputSafety
end
+ def format_in_quote(status, **options)
+ html = format(status)
+ return '' if html.empty?
+ doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
+ doc.search('span.invisible').remove
+ html = doc.css('body')[0].inner_html
+ html.sub!(/^(.+)<\/p>$/, '\1')
+ html = Sanitize.clean(html).delete("\n").truncate(150)
+ html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
+ html.html_safe
+ end
+
def reformat(html)
sanitize(html, Sanitize::Config::MASTODON_STRICT)
rescue ArgumentError
@@ -191,6 +204,12 @@ class Formatter
end
# rubocop:enable Metrics/BlockNesting
+ def quotify(html, status)
+ url = ActivityPub::TagManager.instance.url_for(status.quote)
+ link = encode_and_link_urls(url)
+ html.sub(/(<[^>]+>)\z/, "
QT: #{link}\\1")
+ end
+
def rewrite(text, entities)
text = text.to_s
diff --git a/app/models/status.rb b/app/models/status.rb
index 847921ac2..e06048679 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -23,6 +23,7 @@
# in_reply_to_account_id :bigint(8)
# poll_id :bigint(8)
# deleted_at :datetime
+# quote_id :bigint(8)
#
class Status < ApplicationRecord
@@ -55,6 +56,7 @@ class Status < ApplicationRecord
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
+ belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quoted, optional: true
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy
@@ -63,6 +65,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
+ has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@@ -169,6 +172,10 @@ class Status < ApplicationRecord
!reblog_of_id.nil?
end
+ def quote?
+ !quote_id.nil? && quote
+ end
+
def within_realtime_window?
created_at >= REAL_TIME_WINDOW.ago
end
@@ -229,7 +236,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 replies_count
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 6f9e1ca63..c8e6d939d 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -7,7 +7,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:in_reply_to, :published, :url,
:attributed_to, :to, :cc, :sensitive,
:atom_uri, :in_reply_to_atom_uri,
- :conversation
+ :conversation, :quote_url
attribute :content
attribute :content_map, if: :language?
@@ -125,6 +125,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
+ def quote_url
+ object.quote? ? ActivityPub::TagManager.instance.uri_for(object.quote) : nil
+ end
+
def local?
object.account.local?
end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index bb6df90b7..67449716e 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -18,6 +18,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
belongs_to :reblog, serializer: REST::StatusSerializer
belongs_to :application, if: :show_application?
belongs_to :account, serializer: REST::AccountSerializer
+ belongs_to :quote, serializer: REST::StatusSerializer
has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :ordered_mentions, key: :mentions
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index c5cd3096a..2defecd74 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -67,7 +67,7 @@ class FetchLinkCardService < BaseService
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
else
html = Nokogiri::HTML(@status.text)
- links = html.css('a')
+ links = html.css(':not(.quote-inline) > a')
urls = links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
end
@@ -76,7 +76,7 @@ class FetchLinkCardService < BaseService
def bad_url?(uri)
# Avoid local instance URLs and invalid URLs
- uri.host.blank? || (TagManager.instance.local_url?(uri.to_s) && uri.to_s !~ %r(/users/[\w_-]+/statuses/\w+)) || !%w(http https).include?(uri.scheme)
+ uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
end
# rubocop:disable Naming/MethodParameterName
@@ -132,7 +132,7 @@ class FetchLinkCardService < BaseService
# Most providers rely on