From c77947f15a7cf73cdb7b5cc6fc432fbe594a0498 Mon Sep 17 00:00:00 2001 From: Genbu Hase Date: Sun, 8 Apr 2018 16:56:25 +0900 Subject: [PATCH] [New] Implement a feature of quote --- app/javascript/mastodon/actions/compose.js | 32 ++++++++- .../mastodon/components/status_action_bar.js | 7 ++ .../mastodon/components/status_content.js | 17 +++++ .../mastodon/containers/status_container.js | 5 ++ .../compose/components/compose_form.js | 2 + .../compose/components/quote_indicator.js | 67 +++++++++++++++++++ .../containers/quote_indicator_container.js | 24 +++++++ .../features/status/components/action_bar.js | 7 ++ .../features/status/components/card.js | 4 ++ .../mastodon/features/status/index.js | 6 ++ .../mastodon/locales/defaultMessages.json | 13 ++++ app/javascript/mastodon/locales/en.json | 2 + app/javascript/mastodon/locales/ja.json | 2 + app/javascript/mastodon/reducers/compose.js | 21 ++++++ .../styles/mastodon/components.scss | 28 ++++++-- app/services/fetch_link_card_service.rb | 4 +- 16 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 app/javascript/mastodon/features/compose/components/quote_indicator.js create mode 100644 app/javascript/mastodon/features/compose/containers/quote_indicator_container.js diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 39d31a88f..515ac38de 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -21,6 +21,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'; @@ -106,6 +108,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, @@ -136,13 +157,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 69cd245fb..c659c5d7f 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 801d5d2dd..4dc46e087 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 0c3ce2f62..5d0fe1dc3 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 ae0e0f5da..d979a08fd 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 34c7c4dea..3c65148d1 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, @@ -62,6 +64,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, @@ -112,6 +116,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()); @@ -302,6 +307,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()); @@ -317,10 +324,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 db74c09da..9837ed59e 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 5732ce8ac..0243128e1 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