diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 891403969..26e7bfab2 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -20,6 +20,8 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
+export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
+export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
@@ -100,6 +102,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('/statuses/new');
+ }
+ };
+};
+
+export function cancelQuoteCompose() {
+ return {
+ type: COMPOSE_QUOTE_CANCEL,
+ };
+};
+
export function resetCompose() {
return {
type: COMPOSE_RESET,
@@ -130,13 +151,22 @@ export function directCompose(account, routerHistory) {
export function submitCompose(routerHistory) {
return function (dispatch, getState) {
- const status = getState().getIn(['compose', 'text'], '');
+ let 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', {
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 9981f2449..804e05717 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -24,6 +24,7 @@ const messages = defineMessages({
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
@@ -61,6 +62,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,
@@ -129,6 +131,10 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onBookmark(this.props.status);
}
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status, this.context.router.history);
+ }
+
handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history);
}
@@ -326,6 +332,7 @@ class StatusActionBar extends ImmutablePureComponent {
+
{shareButton}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index bf21a9fd6..5cf7ca802 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -38,6 +38,8 @@ 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];
@@ -46,6 +48,12 @@ 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) {
@@ -125,6 +133,15 @@ export default class StatusContent extends React.PureComponent {
}
}
+ onQuoteClick = (statusId, e) => {
+ let statusUrl = `/statuses/${statusId}`;
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(statusUrl);
+ }
+ }
+
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 9abdec138..43acf597d 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -4,6 +4,7 @@ import Status from '../components/status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../actions/compose';
@@ -99,6 +100,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
+ onQuote (status, router) {
+ dispatch(quoteCompose(status, router));
+ },
+
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index ba2d20cc7..8f9a8a261 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -4,6 +4,7 @@ import Button from '../../../components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import QuoteIndicatorContainer from '../containers/quote_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import AutosuggestInput from '../../../components/autosuggest_input';
import PollButtonContainer from '../containers/poll_button_container';
@@ -209,6 +210,7 @@ class ComposeForm extends ImmutablePureComponent {
+
{
+ this.props.onCancel();
+ }
+
+ handleAccountClick = (e) => {
+ if (e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+ }
+
+ render () {
+ const { status, intl } = this.props;
+
+ if (!status) {
+ return null;
+ }
+
+ const content = { __html: status.get('contentHtml') };
+ const style = {
+ direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
+ };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
new file mode 100644
index 000000000..eb67f3939
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelQuoteCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import QuoteIndicator from '../components/quote_indicator';
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = state => ({
+ status: getStatus(state, state.getIn(['compose', 'quote_from'])),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+ onCancel () {
+ dispatch(cancelQuoteCompose());
+ },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator);
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index ffa2510c0..1da4b52a4 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -18,6 +18,7 @@ const messages = defineMessages({
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ quote: { id: 'status.quote', defaultMessage: 'Quote' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
@@ -56,6 +57,7 @@ class ActionBar extends React.PureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
+ onQuote: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
@@ -82,6 +84,10 @@ class ActionBar extends React.PureComponent {
this.props.onReblog(this.props.status, e);
}
+ handleQuoteClick = () => {
+ this.props.onQuote(this.props.status, this.context.router.history);
+ }
+
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
@@ -277,6 +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 90f9ae7ae..317163648 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -60,6 +60,10 @@ const addAutoPlay = html => {
export default class Card extends React.PureComponent {
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
static propTypes = {
card: ImmutablePropTypes.map,
maxDescription: PropTypes.number,
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index df8362a1b..0cd25955b 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -22,6 +22,7 @@ import {
} from '../../actions/interactions';
import {
replyCompose,
+ quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@@ -259,6 +260,10 @@ class Status extends ImmutablePureComponent {
}
}
+ handleQuoteClick = (status) => {
+ this.props.dispatch(quoteCompose(status, this.context.router.history));
+ }
+
handleDeleteClick = (status, history, withRedraft = false) => {
const { dispatch, intl } = this.props;
@@ -566,6 +571,7 @@ class Status extends ImmutablePureComponent {
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onBookmark={this.handleBookmarkClick}
+ onQuote={this.handleQuoteClick}
onDelete={this.handleDeleteClick}
onDirect={this.handleDirectClick}
onMention={this.handleMentionClick}
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 6d1db70ab..f0522d148 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -449,6 +449,10 @@
"defaultMessage": "This post cannot be boosted",
"id": "status.cannot_reblog"
},
+ {
+ "defaultMessage": "Quote",
+ "id": "status.quote"
+ },
{
"defaultMessage": "Favourite",
"id": "status.favourite"
@@ -1229,6 +1233,15 @@
],
"path": "app/javascript/mastodon/features/compose/components/privacy_dropdown.json"
},
+ {
+ "descriptors": [
+ {
+ "defaultMessage": "Cancel",
+ "id": "quote_indicator.cancel"
+ }
+ ],
+ "path": "app/javascript/mastodon/features/compose/components/quote_indicator.json"
+ },
{
"descriptors": [
{
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index d67ad6862..7ddd4908b 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -350,6 +350,7 @@
"privacy.public.short": "Public",
"privacy.unlisted.long": "Visible for all, but not in public timelines",
"privacy.unlisted.short": "Unlisted",
+ "quote_indicator.cancel": "Cancel",
"refresh": "Refresh",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
@@ -401,6 +402,7 @@
"status.pin": "Pin on profile",
"status.pinned": "Pinned post",
"status.read_more": "Read more",
+ "status.quote": "Quote",
"status.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index f138ede89..732ff3026 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -350,6 +350,7 @@
"privacy.public.short": "公開",
"privacy.unlisted.long": "誰でも閲覧可、公開TLに非表示",
"privacy.unlisted.short": "未収載",
+ "quote_indicator.cancel": "キャンセル",
"refresh": "更新",
"regeneration_indicator.label": "読み込み中…",
"regeneration_indicator.sublabel": "ホームタイムラインは準備中です!",
@@ -401,6 +402,7 @@
"status.pin": "プロフィールに固定表示",
"status.pinned": "固定されたトゥート",
"status.read_more": "もっと見る",
+ "status.quote": "引用ブースト",
"status.reblog": "ブースト",
"status.reblog_private": "ブースト",
"status.reblogged_by": "{name}さんがブースト",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 4c0ba1c36..596e1a68b 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -5,6 +5,8 @@ import {
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_DIRECT,
+ COMPOSE_QUOTE,
+ COMPOSE_QUOTE_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
@@ -59,6 +61,8 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
+ quote_from: null,
+ quote_from_uri: null,
is_composing: false,
is_submitting: false,
is_changing_upload: false,
@@ -102,6 +106,7 @@ function clearAll(state) {
map.set('is_submitting', false);
map.set('is_changing_upload', false);
map.set('in_reply_to', null);
+ map.set('quote_from', null);
map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false);
map.update('media_attachments', list => list.clear());
@@ -292,6 +297,8 @@ export default function compose(state = initialState, action) {
case COMPOSE_REPLY:
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('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('focusDate', new Date());
@@ -307,10 +314,24 @@ export default function compose(state = initialState, action) {
map.set('spoiler_text', '');
}
});
+ case COMPOSE_QUOTE:
+ 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('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());
+ });
case COMPOSE_REPLY_CANCEL:
+ case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
return state.withMutations(map => {
map.set('in_reply_to', null);
+ map.set('quote_from', null);
+ map.set('quote_from_uri', null);
map.set('text', '');
map.set('spoiler', false);
map.set('spoiler_text', '');
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index b0216f5ab..0828ff07c 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -761,26 +761,37 @@
}
.reply-indicator {
+ background: $ui-primary-color;
+}
+
+.quote-indicator {
+ background: $success-green;
+}
+
+.reply-indicator,
+.quote-indicator {
border-radius: 4px;
margin-bottom: 10px;
- background: $ui-primary-color;
padding: 10px;
min-height: 23px;
overflow-y: auto;
flex: 0 2 auto;
}
-.reply-indicator__header {
+.reply-indicator__header,
+.quote-indicator__header {
margin-bottom: 5px;
overflow: hidden;
}
-.reply-indicator__cancel {
+.reply-indicator__cancel,
+.quote-indicator__cancel {
float: right;
line-height: 24px;
}
-.reply-indicator__display-name {
+.reply-indicator__display-name,
+.quote-indicator__display-name {
color: $inverted-text-color;
display: block;
max-width: 100%;
@@ -790,7 +801,8 @@
text-decoration: none;
}
-.reply-indicator__display-avatar {
+.reply-indicator__display-avatar,
+.quote-indicator__display-avatar {
float: left;
margin-right: 5px;
}
@@ -804,7 +816,8 @@
}
.status__content,
-.reply-indicator__content {
+.reply-indicator__content,
+.quote-indicator__content {
position: relative;
font-size: 15px;
line-height: 20px;
@@ -1254,7 +1267,8 @@
margin-left: 6px;
}
-.reply-indicator__content {
+.reply-indicator__content,
+.quote-indicator__content {
color: $inverted-text-color;
font-size: 14px;
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index fa1636e41..c5cd3096a 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -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) || !%w(http https).include?(uri.scheme)
+ uri.host.blank? || (TagManager.instance.local_url?(uri.to_s) && uri.to_s !~ %r(/users/[\w_-]+/statuses/\w+)) || !%w(http https).include?(uri.scheme)
end
# rubocop:disable Naming/MethodParameterName
@@ -132,7 +132,7 @@ class FetchLinkCardService < BaseService
# Most providers rely on