diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 2443a28533..5d09a86d3c 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -62,6 +62,7 @@ export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; export const COMPOSE_EXPIRATION_INSERT = 'COMPOSE_EXPIRATION_INSERT'; +export const COMPOSE_REFERENCE_INSERT = 'COMPOSE_REFERENCE_INSERT'; export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; @@ -770,6 +771,14 @@ export function insertExpirationCompose(position, data) { }; } +export function insertReferenceCompose(position, url) { + return { + type: COMPOSE_REFERENCE_INSERT, + position, + url, + }; +} + export function changeComposing(value) { return { type: COMPOSE_COMPOSING_CHANGE, diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 70e0ff0995..89d19c3da0 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -49,6 +49,7 @@ const messages = defineMessages({ admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, + reference: { id: 'status.reference', defaultMessage: 'Add reference' }, hide: { id: 'status.hide', defaultMessage: 'Hide post' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, @@ -251,6 +252,10 @@ class StatusActionBar extends ImmutablePureComponent { navigator.clipboard.writeText(url); }; + handleReference = () => { + this.props.onReference(this.props.status); + }; + handleHideClick = () => { this.props.onFilter(); }; @@ -289,6 +294,11 @@ class StatusActionBar extends ImmutablePureComponent { menu.push(null); menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancelReblog : messages.reblog), action: this.handleReblogForceModalClick }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference }); + } + menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); if (writtenByMe && pinnableStatus) { diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 026f45bedb..31bbb3708a 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -13,6 +13,7 @@ import { replyCompose, mentionCompose, directCompose, + insertReferenceCompose, } from '../actions/compose'; import { blockDomain, @@ -192,6 +193,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ }); }, + onReference (status) { + dispatch(insertReferenceCompose(0, status.get('url'))); + }, + onTranslate (status) { if (status.get('translation')) { dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 4a6c3c095e..b4bb1f2de4 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -42,6 +42,7 @@ const messages = defineMessages({ admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, + reference: { id: 'status.reference', defaultMessage: 'Add reference' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, @@ -69,6 +70,7 @@ class ActionBar extends PureComponent { onReblogForceModal: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired, onEmojiReact: PropTypes.func.isRequired, + onReference: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, onEdit: PropTypes.func.isRequired, @@ -190,6 +192,10 @@ class ActionBar extends PureComponent { navigator.clipboard.writeText(url); }; + handleReference = () => { + this.props.onReference(this.props.status); + }; + handleEmojiPick = (data) => { this.props.onEmojiReact(this.props.status, data); }; @@ -227,6 +233,11 @@ class ActionBar extends PureComponent { menu.push(null); menu.push({ text: intl.formatMessage(messages.reblog), action: this.handleReblogForceModalClick }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference }); + } + menu.push(null); } diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index c762fa84ef..ffca0063f1 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -28,6 +28,7 @@ import { replyCompose, mentionCompose, directCompose, + insertReferenceCompose, } from '../../actions/compose'; import { blockDomain, @@ -356,6 +357,10 @@ class Status extends ImmutablePureComponent { this.handleReblogClick(status, e, true); }; + handleReference = (status) => { + this.props.dispatch(insertReferenceCompose(0, status.get('url'))); + }; + handleBookmarkClick = (status) => { if (status.get('bookmarked')) { this.props.dispatch(unbookmark(status)); @@ -717,6 +722,7 @@ class Status extends ImmutablePureComponent { onEmojiReact={this.handleEmojiReact} onReblog={this.handleReblogClick} onReblogForceModal={this.handleReblogForceModalClick} + onReference={this.handleReference} onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} onEdit={this.handleEditClick} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 8a23e39178..cfb68c5a27 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -419,6 +419,7 @@ "notification.poll": "A poll you have voted in has ended", "notification.reblog": "{name} boosted your post", "notification.status": "{name} just posted", + "notification.status_reference": "{name} refered your post", "notification.update": "{name} edited a post", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 6dab3962df..a13e217288 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -36,6 +36,7 @@ import { COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, COMPOSE_EXPIRATION_INSERT, + COMPOSE_REFERENCE_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_SUCCESS, COMPOSE_UPLOAD_CHANGE_FAIL, @@ -238,6 +239,41 @@ const insertExpiration = (state, position, data) => { }); }; +const insertReference = (state, url) => { + const oldText = state.get('text'); + + if (oldText.indexOf(`BT ${url}`) >= 0) { + return state; + } + + let newLine = '\n\n'; + if (oldText.length === 0) newLine = ''; + else if (oldText[oldText.length - 1] === '\n') { + if (oldText.length === 1 || oldText[oldText.length - 2] === '\n') { + newLine = ''; + } else { + newLine = '\n'; + } + } + + if (oldText.length > 0) { + const lastLine = oldText.slice(oldText.lastIndexOf('\n') + 1, oldText.length - 1); + if (lastLine.startsWith('BT ')) { + newLine = '\n'; + } + } + + const referenceText = `${newLine}BT ${url}`; + const text = `${oldText}${referenceText}`; + + return state.merge({ + text, + focusDate: new Date(), + caretPosition: text.length - referenceText.length, + idempotencyKey: uuid(), + }); +}; + const privacyPreference = (a, b) => { const order = ['public', 'public_unlisted', 'unlisted', 'login', 'private', 'direct']; return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; @@ -476,6 +512,8 @@ export default function compose(state = initialState, action) { return insertEmoji(state, action.position, action.emoji, action.needsSpace); case COMPOSE_EXPIRATION_INSERT: return insertExpiration(state, action.position, action.data); + case COMPOSE_REFERENCE_INSERT: + return insertReference(state, action.url); case COMPOSE_UPLOAD_CHANGE_SUCCESS: return state .set('is_changing_upload', false) diff --git a/app/services/process_references_service.rb b/app/services/process_references_service.rb index 596da748b9..69d460c6d4 100644 --- a/app/services/process_references_service.rb +++ b/app/services/process_references_service.rb @@ -4,7 +4,7 @@ class ProcessReferencesService < BaseService include Payloadable DOMAIN = ENV['WEB_DOMAIN'] || ENV.fetch('LOCAL_DOMAIN', nil) - REFURL_EXP = /(RT|QT|BT|RN)((:)? +|:)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/ + REFURL_EXP = /(RT|QT|BT|RN|RE)((:)? +|:)(#{URI::DEFAULT_PARSER.make_regexp(%w(http https))})/ STATUSID_EXP = %r{(http|https)://#{DOMAIN}/@[a-zA-Z0-9]+/([0-9]{16,})} def call(status, reference_parameters, save_records: true, urls: nil) @@ -69,9 +69,10 @@ class ProcessReferencesService < BaseService end def create_notifications! - return if @added_objects.empty? + local_reference_objects = @added_objects.filter { |ref| ref.target_status.account.local? } + return if local_reference_objects.empty? - LocalNotificationWorker.push_bulk(@added_objects) do |ref| + LocalNotificationWorker.push_bulk(local_reference_objects) do |ref| [ref.target_status.account_id, ref.id, 'StatusReference', 'status_reference'] end end