From be6f4e013a02a2bae07a02dbabd8e9b6721b463d Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 11 May 2021 21:17:38 +0900 Subject: [PATCH 01/36] Fix eslint error (#16212) --- app/javascript/mastodon/actions/boosts.js | 2 +- .../features/notifications/containers/notification_container.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/javascript/mastodon/actions/boosts.js b/app/javascript/mastodon/actions/boosts.js index 6e14065d6..c0f0f3acc 100644 --- a/app/javascript/mastodon/actions/boosts.js +++ b/app/javascript/mastodon/actions/boosts.js @@ -11,7 +11,7 @@ export function initBoostModal(props) { dispatch({ type: BOOSTS_INIT_MODAL, - privacy + privacy, }); dispatch(openModal('BOOST', props)); diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 555d5e1b5..5c984197f 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -2,7 +2,6 @@ import { connect } from 'react-redux'; import { makeGetNotification, makeGetStatus } from '../../../selectors'; import Notification from '../components/notification'; import { initBoostModal } from '../../../actions/boosts'; -import { openModal } from '../../../actions/modal'; import { mentionCompose } from '../../../actions/compose'; import { reblog, From 7032d4f5828a3b9c7a758604710a57137d24dc66 Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 11 May 2021 21:18:33 +0900 Subject: [PATCH 02/36] Ignore brakeman false positive warning (#16213) --- config/brakeman.ignore | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 2d47a9aaf..35f2c3178 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -7,7 +7,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/report.rb", - "line": 112, + "line": 113, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "Admin::ActionLog.from(\"(#{[Admin::ActionLog.where(:target_type => \"Report\", :target_id => id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Account\", :target_id => target_account_id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)].map do\n \"(#{query.to_sql})\"\n end.join(\" UNION ALL \")}) AS admin_action_logs\")", "render_path": null, @@ -67,7 +67,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/account.rb", - "line": 491, + "line": 479, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "find_by_sql([\" WITH first_degree AS (\\n SELECT target_account_id\\n FROM follows\\n WHERE account_id = ?\\n UNION ALL\\n SELECT ?\\n )\\n SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n WHERE accounts.id IN (SELECT * FROM first_degree)\\n AND #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])", "render_path": null, @@ -120,6 +120,26 @@ "confidence": "High", "note": "" }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "874be88fedf4c680926845e9a588d3197765a6ccbfdd76466b44cc00151c612e", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/api/v1/admin/reports_controller.rb", + "line": 78, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.permit(:resolved, :account_id, :target_account_id)", + "render_path": null, + "location": { + "type": "method", + "class": "Api::V1::Admin::ReportsController", + "method": "filter_params" + }, + "user_input": ":account_id", + "confidence": "High", + "note": "" + }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -127,7 +147,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/account.rb", - "line": 460, + "line": 448, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "find_by_sql([\" SELECT\\n accounts.*,\\n ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, limit, offset])", "render_path": null, @@ -207,7 +227,7 @@ "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/account.rb", - "line": 507, + "line": 495, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "find_by_sql([\" SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])", "render_path": null, @@ -241,6 +261,6 @@ "note": "" } ], - "updated": "2020-12-07 01:17:13 +0100", - "brakeman_version": "4.10.0" + "updated": "2021-05-11 20:22:27 +0900", + "brakeman_version": "5.0.1" } From b5ad787ebf33b475379d38211c646845f1dcd934 Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 11 May 2021 21:19:01 +0900 Subject: [PATCH 03/36] Fix rubocop warning (#16214) --- app/lib/activitypub/activity/create.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index e46361c14..9a2960507 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -46,7 +46,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity? lock_or_fail("create:#{object_uri}") do - return if delete_arrived_first?(object_uri) || poll_vote? # rubocop:disable Lint/NonLocalExitFromIterator + return if delete_arrived_first?(object_uri) || poll_vote? @status = find_existing_status From c403c3695b1943882bf88afa9caf55bd8c6acc2f Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Tue, 11 May 2021 21:19:22 +0900 Subject: [PATCH 04/36] Fix to be able to redownload avatar and header (#16190) * Fix to reset if header and avatar download fails * Add RedownloadAvatarWorker and RedownloadHeaderWorker --- app/models/concerns/account_avatar.rb | 2 +- app/models/concerns/account_header.rb | 2 +- app/models/concerns/remotable.rb | 2 ++ .../activitypub/process_account_service.rb | 12 ++++++-- app/workers/redownload_avatar_worker.rb | 29 +++++++++++++++++++ app/workers/redownload_header_worker.rb | 29 +++++++++++++++++++ 6 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 app/workers/redownload_avatar_worker.rb create mode 100644 app/workers/redownload_header_worker.rb diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb index 2d5ebfca3..1af53ed23 100644 --- a/app/models/concerns/account_avatar.rb +++ b/app/models/concerns/account_avatar.rb @@ -21,7 +21,7 @@ module AccountAvatar has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail] validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES validates_attachment_size :avatar, less_than: LIMIT - remotable_attachment :avatar, LIMIT + remotable_attachment :avatar, LIMIT, suppress_errors: false end def avatar_original_url diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb index 067e166eb..72a3d0566 100644 --- a/app/models/concerns/account_header.rb +++ b/app/models/concerns/account_header.rb @@ -22,7 +22,7 @@ module AccountHeader has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail] validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES validates_attachment_size :header, less_than: LIMIT - remotable_attachment :header, LIMIT + remotable_attachment :header, LIMIT, suppress_errors: false end def header_original_url diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 56b9c0164..ffe8a7565 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -28,9 +28,11 @@ module Remotable end rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}" + public_send("#{attachment_name}=", nil) if public_send("#{attachment_name}_file_name").present? raise e unless suppress_errors rescue Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, Paperclip::Error, Mastodon::DimensionsValidationError, Mastodon::StreamValidationError => e Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}" + public_send("#{attachment_name}=", nil) if public_send("#{attachment_name}_file_name").present? end nil diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index bb2e8f665..7e268f4d4 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -106,8 +106,16 @@ class ActivityPub::ProcessAccountService < BaseService end def set_fetchable_attributes! - @account.avatar_remote_url = image_url('icon') || '' unless skip_download? - @account.header_remote_url = image_url('image') || '' unless skip_download? + begin + @account.avatar_remote_url = image_url('icon') || '' unless skip_download? + rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError + RedownloadAvatarWorker.perform_in(rand(30..600).seconds, @account.id) + end + begin + @account.header_remote_url = image_url('image') || '' unless skip_download? + rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError + RedownloadHeaderWorker.perform_in(rand(30..600).seconds, @account.id) + end @account.statuses_count = outbox_total_items if outbox_total_items.present? @account.following_count = following_total_items if following_total_items.present? @account.followers_count = followers_total_items if followers_total_items.present? diff --git a/app/workers/redownload_avatar_worker.rb b/app/workers/redownload_avatar_worker.rb new file mode 100644 index 000000000..df17b7718 --- /dev/null +++ b/app/workers/redownload_avatar_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class RedownloadAvatarWorker + include Sidekiq::Worker + include ExponentialBackoff + include JsonLdHelper + + sidekiq_options queue: 'pull', retry: 7 + + def perform(id) + account = Account.find(id) + + return if account.suspended? || DomainBlock.rule_for(account.domain)&.reject_media? + return if account.avatar_remote_url.blank? || account.avatar_file_name.present? + + account.reset_avatar! + account.save! + rescue ActiveRecord::RecordNotFound + # Do nothing + rescue Mastodon::UnexpectedResponseError => e + response = e.response + + if response_error_unsalvageable?(response) + # Give up + else + raise e + end + end +end diff --git a/app/workers/redownload_header_worker.rb b/app/workers/redownload_header_worker.rb new file mode 100644 index 000000000..3b142ec5f --- /dev/null +++ b/app/workers/redownload_header_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class RedownloadHeaderWorker + include Sidekiq::Worker + include ExponentialBackoff + include JsonLdHelper + + sidekiq_options queue: 'pull', retry: 7 + + def perform(id) + account = Account.find(id) + + return if account.suspended? || DomainBlock.rule_for(account.domain)&.reject_media? + return if account.header_remote_url.blank? || account.header_file_name.present? + + account.reset_header! + account.save! + rescue ActiveRecord::RecordNotFound + # Do nothing + rescue Mastodon::UnexpectedResponseError => e + response = e.response + + if response_error_unsalvageable?(response) + # Give up + else + raise e + end + end +end From be353bccbbdd932ace6c42fee9d0a0e58330f0d3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 11 May 2021 19:14:59 +0200 Subject: [PATCH 05/36] Fix error when rendering actor with hashtags in bio (#16218) Fix #16217, regression from #16160 --- app/serializers/activitypub/actor_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index d92aae7b3..a7d948976 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -177,7 +177,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer end def href - explore_hashtag_url(object) + tag_url(object) end def name From 5ed5f62705a0d9dd58f4eb617445683ee8bd2378 Mon Sep 17 00:00:00 2001 From: rinsuki <428rinsuki+git@gmail.com> Date: Wed, 12 May 2021 02:15:11 +0900 Subject: [PATCH 06/36] Fix animated GIF generates animated thumbnail (#16216) --- lib/paperclip/gif_transcoder.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb index 74aa1a0b2..d14465c01 100644 --- a/lib/paperclip/gif_transcoder.rb +++ b/lib/paperclip/gif_transcoder.rb @@ -108,9 +108,11 @@ module Paperclip final_file = Paperclip::Transcoder.make(file, options, attachment) - attachment.instance.file_file_name = File.basename(attachment.instance.file_file_name, '.*') + '.mp4' - attachment.instance.file_content_type = 'video/mp4' - attachment.instance.type = MediaAttachment.types[:gifv] + if options[:style] == :original + attachment.instance.file_file_name = File.basename(attachment.instance.file_file_name, '.*') + '.mp4' + attachment.instance.file_content_type = 'video/mp4' + attachment.instance.type = MediaAttachment.types[:gifv] + end final_file end @@ -118,7 +120,7 @@ module Paperclip private def needs_convert? - options[:style] == :original && GifReader.animated?(file.path) + GifReader.animated?(file.path) end end end From 0464240f196ff7f98b887c23fd7905be8dc432dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9lanie=20Chauvel?= Date: Tue, 11 May 2021 21:15:57 +0200 Subject: [PATCH 07/36] Fix dialog close button (#16219) * Fix dialog close button being white on almost white * Make dialog close button slightly bigger --- .../mastodon/features/ui/components/focal_point_modal.js | 2 +- .../mastodon/features/ui/components/report_modal.js | 2 +- app/javascript/styles/mastodon/components.scss | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index ffa783e3b..3457b7633 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -309,7 +309,7 @@ class FocalPointModal extends ImmutablePureComponent { return (
- +
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index 2e41f784d..f4f0a3884 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -91,7 +91,7 @@ class ReportModal extends ImmutablePureComponent { return (
- + {account.get('acct')} }} />
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index d3dd1af60..f1f975746 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5297,9 +5297,10 @@ a.status-card.compact:hover { .report-modal__target { padding: 15px; - .media-modal__close { - top: 14px; - right: 15px; + .report-modal__close { + position: absolute; + top: 10px; + right: 10px; } } From 4f747d9f8355f5b226ae29b8d63bd83e3886db58 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 11 May 2021 21:16:24 +0200 Subject: [PATCH 08/36] Fix follow recommendations UI in advanced layout (#16215) --- .../mastodon/features/follow_recommendations/index.js | 2 +- app/javascript/styles/mastodon/components.scss | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/features/follow_recommendations/index.js b/app/javascript/mastodon/features/follow_recommendations/index.js index a35ff3e82..26c8b2471 100644 --- a/app/javascript/mastodon/features/follow_recommendations/index.js +++ b/app/javascript/mastodon/features/follow_recommendations/index.js @@ -76,7 +76,7 @@ class FollowRecommendations extends ImmutablePureComponent { return ( -
+

diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index f1f975746..b0216f5ab 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2508,13 +2508,20 @@ a.account__display-name { } } +.follow-recommendations-container { + display: flex; + flex-direction: column; +} + .column-actions { display: flex; - align-items: center; + align-items: start; justify-content: center; padding: 40px; padding-top: 40px; padding-bottom: 200px; + flex-grow: 1; + position: relative; &__background { position: absolute; From 70f6f2e9b70afe5af17ee9aaec777565bdef8aed Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 11 May 2021 22:21:47 +0200 Subject: [PATCH 09/36] Fix empty CW when a content-less toot with a CW is encountered twice (#16220) --- app/javascript/mastodon/actions/importer/normalizer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index abd5681d4..5002292b9 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -60,6 +60,7 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); + normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); } else { // If the status has a CW but no contents, treat the CW as if it were the From 42f8e5801e1a4d10bae7bf4f5e298a93109d9665 Mon Sep 17 00:00:00 2001 From: Genbu Hase Date: Sun, 8 Apr 2018 16:56:25 +0900 Subject: [PATCH 10/36] [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 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