From 64aac3073340dbc92c33f5f1c6f76dcafa77a450 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 29 Jun 2020 13:56:55 +0200 Subject: Add customizable thumbnails for audio and video attachments (#14145) - Change audio files to not be stripped of metadata - Automatically extract cover art from audio if it exists - Add `thumbnail` parameter to `POST /api/v1/media`, `POST /api/v2/media` and `PUT /api/v1/media/:id` - Add `icon` to represent it in attachments in ActivityPub - Fix `preview_url` containing URL of missing missing image when there is no thumbnail instead of null - Fix duration of audio not being displayed on public pages until the file is loaded --- app/models/concerns/remotable.rb | 29 +++++------ app/models/media_attachment.rb | 108 ++++++++++++++++++++++++++------------- 2 files changed, 86 insertions(+), 51 deletions(-) (limited to 'app/models') diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index c728a460e..6fc1dcc26 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -4,12 +4,12 @@ module Remotable extend ActiveSupport::Concern class_methods do - def remotable_attachment(attachment_name, limit, suppress_errors: true) - attribute_name = "#{attachment_name}_remote_url".to_sym - method_name = "#{attribute_name}=".to_sym - alt_method_name = "reset_#{attachment_name}!".to_sym + def remotable_attachment(attachment_name, limit, suppress_errors: true, download_on_assign: true, attribute_name: nil) + attribute_name ||= "#{attachment_name}_remote_url".to_sym + + define_method("download_#{attachment_name}!") do + url = self[attribute_name] - define_method method_name do |url| return if url.blank? begin @@ -18,7 +18,7 @@ module Remotable return end - return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? || (self[attribute_name] == url && send("#{attachment_name}_file_name").present?) + return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? begin Request.new(:get, url).perform do |response| @@ -36,10 +36,8 @@ module Remotable basename = SecureRandom.hex(8) - send("#{attachment_name}_file_name=", basename + extname) - send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit))) - - self[attribute_name] = url if has_attribute?(attribute_name) + public_send("#{attachment_name}_file_name=", basename + extname) + public_send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit))) end rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}" @@ -50,14 +48,15 @@ module Remotable end end - define_method alt_method_name do - url = self[attribute_name] + define_method("#{attribute_name}=") do |url| + return if self[attribute_name] == url && public_send("#{attachment_name}_file_name").present? - return if url.blank? + self[attribute_name] = url - self[attribute_name] = '' - send(method_name, url) + public_send("download_#{attachment_name}!") if download_on_assign end + + alias_method("reset_#{attachment_name}!", "download_#{attachment_name}!") end end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index d44467009..f67566a18 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -21,6 +21,11 @@ # blurhash :string # processing :integer # file_storage_schema_version :integer +# thumbnail_file_name :string +# thumbnail_content_type :string +# thumbnail_file_size :integer +# thumbnail_updated_at :datetime +# thumbnail_remote_url :string # class MediaAttachment < ApplicationRecord @@ -49,13 +54,13 @@ class MediaAttachment < ApplicationRecord original: { pixels: 1_638_400, # 1280x1280px file_geometry_parser: FastGeometryParser, - }, + }.freeze, small: { pixels: 160_000, # 400x400px file_geometry_parser: FastGeometryParser, blurhash: BLURHASH_OPTIONS, - }, + }.freeze, }.freeze VIDEO_FORMAT = { @@ -74,14 +79,14 @@ class MediaAttachment < ApplicationRecord 'frames:v' => 60 * 60 * 3, 'crf' => 18, 'map_metadata' => '-1', - }, - }, + }.freeze, + }.freeze, }.freeze VIDEO_PASSTHROUGH_OPTIONS = { - video_codecs: ['h264'], - audio_codecs: ['aac', nil], - colorspaces: ['yuv420p'], + video_codecs: ['h264'].freeze, + audio_codecs: ['aac', nil].freeze, + colorspaces: ['yuv420p'].freeze, options: { format: 'mp4', convert_options: { @@ -90,9 +95,9 @@ class MediaAttachment < ApplicationRecord 'map_metadata' => '-1', 'c:v' => 'copy', 'c:a' => 'copy', - }, - }, - }, + }.freeze, + }.freeze, + }.freeze, }.freeze VIDEO_STYLES = { @@ -101,15 +106,15 @@ class MediaAttachment < ApplicationRecord output: { 'loglevel' => 'fatal', vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', - }, - }, + }.freeze, + }.freeze, format: 'png', time: 0, file_geometry_parser: FastGeometryParser, blurhash: BLURHASH_OPTIONS, - }, + }.freeze, - original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS), + original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS).freeze, }.freeze AUDIO_STYLES = { @@ -119,16 +124,23 @@ class MediaAttachment < ApplicationRecord convert_options: { output: { 'loglevel' => 'fatal', - 'map_metadata' => '-1', 'q:a' => 2, - }, - }, - }, + }.freeze, + }.freeze, + }.freeze, }.freeze VIDEO_CONVERTED_STYLES = { - small: VIDEO_STYLES[:small], - original: VIDEO_FORMAT, + small: VIDEO_STYLES[:small].freeze, + original: VIDEO_FORMAT.freeze, + }.freeze + + THUMBNAIL_STYLES = { + original: IMAGE_STYLES[:small].freeze, + }.freeze + + GLOBAL_CONVERT_OPTIONS = { + all: '-quality 90 -strip +set modify-date +set create-date', }.freeze IMAGE_LIMIT = 10.megabytes @@ -144,18 +156,28 @@ class MediaAttachment < ApplicationRecord has_attached_file :file, styles: ->(f) { file_styles f }, processors: ->(f) { file_processors f }, - convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' } + convert_options: GLOBAL_CONVERT_OPTIONS validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format? validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :larger_media_format? - remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false + remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false, download_on_assign: false, attribute_name: :remote_url + + has_attached_file :thumbnail, + styles: THUMBNAIL_STYLES, + processors: [:lazy_thumbnail, :blurhash_transcoder], + convert_options: GLOBAL_CONVERT_OPTIONS + + validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES + validates_attachment_size :thumbnail, less_than: IMAGE_LIMIT + remotable_attachment :thumbnail, IMAGE_LIMIT, suppress_errors: true, download_on_assign: false include Attachmentable validates :account, presence: true validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local? validates :file, presence: true, if: :local? + validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? } scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) } scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) } @@ -215,16 +237,21 @@ class MediaAttachment < ApplicationRecord @delay_processing end + def delay_processing_for_attachment?(attachment_name) + @delay_processing && attachment_name == :file + end + after_commit :enqueue_processing, on: :create after_commit :reset_parent_cache, on: :update before_create :prepare_description, unless: :local? before_create :set_shortcode before_create :set_processing - before_create :set_meta - before_post_process :set_type_and_extension - before_post_process :check_video_dimensions + after_post_process :set_meta + + before_file_post_process :set_type_and_extension + before_file_post_process :check_video_dimensions class << self def supported_mime_types @@ -237,25 +264,25 @@ class MediaAttachment < ApplicationRecord private - def file_styles(f) - if f.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type) + def file_styles(attachment) + if attachment.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(attachment.instance.file_content_type) VIDEO_CONVERTED_STYLES - elsif IMAGE_MIME_TYPES.include?(f.instance.file_content_type) + elsif IMAGE_MIME_TYPES.include?(attachment.instance.file_content_type) IMAGE_STYLES - elsif VIDEO_MIME_TYPES.include?(f.instance.file_content_type) + elsif VIDEO_MIME_TYPES.include?(attachment.instance.file_content_type) VIDEO_STYLES else AUDIO_STYLES end end - def file_processors(f) - if f.file_content_type == 'image/gif' + def file_processors(instance) + if instance.file_content_type == 'image/gif' [:gif_transcoder, :blurhash_transcoder] - elsif VIDEO_MIME_TYPES.include?(f.file_content_type) + elsif VIDEO_MIME_TYPES.include?(instance.file_content_type) [:video_transcoder, :blurhash_transcoder, :type_corrector] - elsif AUDIO_MIME_TYPES.include?(f.file_content_type) - [:transcoder, :type_corrector] + elsif AUDIO_MIME_TYPES.include?(instance.file_content_type) + [:image_extractor, :transcoder, :type_corrector] else [:lazy_thumbnail, :blurhash_transcoder, :type_corrector] end @@ -298,7 +325,7 @@ class MediaAttachment < ApplicationRecord def check_video_dimensions return unless (video? || gifv?) && file.queued_for_write[:original].present? - movie = FFMPEG::Movie.new(file.queued_for_write[:original].path) + movie = ffmpeg_data(file.queued_for_write[:original].path) return unless movie.valid? @@ -317,6 +344,8 @@ class MediaAttachment < ApplicationRecord meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file) end + meta[:small] = image_geometry(thumbnail.queued_for_write[:original]) if thumbnail.queued_for_write.key?(:original) + meta end @@ -334,7 +363,7 @@ class MediaAttachment < ApplicationRecord end def video_metadata(file) - movie = FFMPEG::Movie.new(file.path) + movie = ffmpeg_data(file.path) return {} unless movie.valid? @@ -347,6 +376,13 @@ class MediaAttachment < ApplicationRecord }.compact end + # We call this method about 3 different times on potentially different + # paths but ultimately the same file, so it makes sense to memoize the + # result while disregarding the path + def ffmpeg_data(path = nil) + @ffmpeg_data ||= FFMPEG::Movie.new(path) + end + def enqueue_processing PostProcessMediaWorker.perform_async(id) if delay_processing? end -- cgit From 1b198d64890de3eed5562c9b485ed8cafbff059f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 29 Jun 2020 17:59:04 +0200 Subject: Fix trying to write non-existent image remote URL attribute on preview cards (#14181) Regression from #14145 --- app/models/concerns/remotable.rb | 8 +- spec/models/concerns/remotable_spec.rb | 241 +++++++++++++++++---------------- 2 files changed, 128 insertions(+), 121 deletions(-) (limited to 'app/models') diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 6fc1dcc26..53ebc0835 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -7,8 +7,8 @@ module Remotable def remotable_attachment(attachment_name, limit, suppress_errors: true, download_on_assign: true, attribute_name: nil) attribute_name ||= "#{attachment_name}_remote_url".to_sym - define_method("download_#{attachment_name}!") do - url = self[attribute_name] + define_method("download_#{attachment_name}!") do |url = nil| + url ||= self[attribute_name] return if url.blank? @@ -51,9 +51,9 @@ module Remotable define_method("#{attribute_name}=") do |url| return if self[attribute_name] == url && public_send("#{attachment_name}_file_name").present? - self[attribute_name] = url + self[attribute_name] = url if has_attribute?(attribute_name) - public_send("download_#{attachment_name}!") if download_on_assign + public_send("download_#{attachment_name}!", url) if download_on_assign end alias_method("reset_#{attachment_name}!", "download_#{attachment_name}!") diff --git a/spec/models/concerns/remotable_spec.rb b/spec/models/concerns/remotable_spec.rb index 6957b044f..2e6c8a9c6 100644 --- a/spec/models/concerns/remotable_spec.rb +++ b/spec/models/concerns/remotable_spec.rb @@ -29,170 +29,177 @@ RSpec.describe Remotable do end end - context 'Remotable module is included' do - before do - class Foo - include Remotable - remotable_attachment :hoge, 1.kilobyte - end - end + before do + class Foo + include Remotable - let(:attribute_name) { "#{hoge}_remote_url".to_sym } - let(:code) { 200 } - let(:file) { 'filename="foo.txt"' } - let(:foo) { Foo.new } - let(:headers) { { 'content-disposition' => file } } - let(:hoge) { :hoge } - let(:url) { 'https://google.com' } - - let(:request) do - stub_request(:get, url) - .to_return(status: code, headers: headers) + remotable_attachment :hoge, 1.kilobyte end + end - it 'defines a method #hoge_remote_url=' do - expect(foo).to respond_to(:hoge_remote_url=) - end + let(:attribute_name) { "#{hoge}_remote_url".to_sym } + let(:code) { 200 } + let(:file) { 'filename="foo.txt"' } + let(:foo) { Foo.new } + let(:headers) { { 'content-disposition' => file } } + let(:hoge) { :hoge } + let(:url) { 'https://google.com' } + + it 'defines a method #hoge_remote_url=' do + expect(foo).to respond_to(:hoge_remote_url=) + end - it 'defines a method #reset_hoge!' do - expect(foo).to respond_to(:reset_hoge!) + it 'defines a method #reset_hoge!' do + expect(foo).to respond_to(:reset_hoge!) + end + + it 'defines a method #download_hoge!' do + expect(foo).to respond_to(:download_hoge!) + end + + describe '#hoge_remote_url=' do + before do + stub_request(:get, url).to_return(status: code, headers: headers) end - it 'defines a method #download_hoge!' do - expect(foo).to respond_to(:download_hoge!) + it 'always returns its argument' do + [nil, '', [], {}].each do |arg| + expect(foo.hoge_remote_url = arg).to be arg + end end - describe '#hoge_remote_url=' do + context 'with an invalid URL' do before do - request + allow(Addressable::URI).to receive_message_chain(:parse, :normalize).with(url).with(no_args).and_raise(Addressable::URI::InvalidURIError) end - it 'always returns arg' do - [nil, '', [], {}].each do |arg| - expect(foo.hoge_remote_url = arg).to be arg - end + it 'makes no request' do + foo.hoge_remote_url = url + expect(a_request(:get, url)).to_not have_been_made end + end - context 'Addressable::URI::InvalidURIError raised' do - it 'makes no request' do - allow(Addressable::URI).to receive_message_chain(:parse, :normalize) - .with(url).with(no_args).and_raise(Addressable::URI::InvalidURIError) + context 'with scheme that is neither http nor https' do + let(:url) { 'ftp://google.com' } - foo.hoge_remote_url = url - expect(request).not_to have_been_requested - end + it 'makes no request' do + foo.hoge_remote_url = url + expect(a_request(:get, url)).to_not have_been_made end + end - context 'scheme is neither http nor https' do - let(:url) { 'ftp://google.com' } + context 'with relative URL' do + let(:url) { 'https:///path' } - it 'makes no request' do - foo.hoge_remote_url = url - expect(request).not_to have_been_requested - end + it 'makes no request' do + foo.hoge_remote_url = url + expect(a_request(:get, url)).to_not have_been_made end + end - context 'parsed_url.host is empty' do - it 'makes no request' do - parsed_url = double(scheme: 'https', host: double(blank?: true)) - allow(Addressable::URI).to receive_message_chain(:parse, :normalize) - .with(url).with(no_args).and_return(parsed_url) + context 'when URL has not changed' do + it 'makes no request if file is already saved' do + allow(foo).to receive(:[]).with(attribute_name).and_return(url) + allow(foo).to receive(:hoge_file_name).and_return('foo.jpg') - foo.hoge_remote_url = url - expect(request).not_to have_been_requested - end + foo.hoge_remote_url = url + expect(a_request(:get, url)).to_not have_been_made end - context 'parsed_url.host is nil' do - it 'makes no request' do - parsed_url = Addressable::URI.parse('https:https://example.com/path/file.png') - allow(Addressable::URI).to receive_message_chain(:parse, :normalize) - .with(url).with(no_args).and_return(parsed_url) + it 'makes request if file is not already saved' do + allow(foo).to receive(:[]).with(attribute_name).and_return(url) + allow(foo).to receive(:hoge_file_name).and_return(nil) - foo.hoge_remote_url = url - expect(request).not_to have_been_requested - end + foo.hoge_remote_url = url + expect(a_request(:get, url)).to have_been_made end + end - context 'foo[attribute_name] == url' do - it 'makes no request if file is saved' do - allow(foo).to receive(:[]).with(attribute_name).and_return(url) - allow(foo).to receive(:hoge_file_name).and_return('foo.jpg') + context 'when instance has no attribute for URL' do + before do + allow(foo).to receive(:has_attribute?).with(attribute_name).and_return(false) + end - foo.hoge_remote_url = url - expect(request).not_to have_been_requested - end + it 'does not try to write attribute' do + expect(foo).to_not receive('[]=').with(attribute_name, url) + foo.hoge_remote_url = url + end + end - it 'makes request if file is not saved' do - allow(foo).to receive(:[]).with(attribute_name).and_return(url) - allow(foo).to receive(:hoge_file_name).and_return(nil) + context 'when instance has an attribute for URL' do + before do + allow(foo).to receive(:has_attribute?).with(attribute_name).and_return(true) + end - foo.hoge_remote_url = url - expect(request).to have_been_requested - end + it 'does not try to write attribute' do + expect(foo).to receive('[]=').with(attribute_name, url) + foo.hoge_remote_url = url + end + end + + context 'with a valid URL' do + it 'makes a request' do + foo.hoge_remote_url = url + expect(a_request(:get, url)).to have_been_made end - context "scheme is https, parsed_url.host isn't empty, and foo[attribute_name] != url" do - it 'makes a request' do - foo.hoge_remote_url = url - expect(request).to have_been_requested - end + context 'when the response is not successful' do + let(:code) { 500 } - context 'response.code != 200' do - let(:code) { 500 } + it 'does not assign file' do + expect(foo).not_to receive(:public_send).with("#{hoge}=", any_args) + expect(foo).not_to receive(:public_send).with("#{hoge}_file_name=", any_args) - it 'calls not send' do - expect(foo).not_to receive(:public_send).with("#{hoge}=", any_args) - expect(foo).not_to receive(:public_send).with("#{hoge}_file_name=", any_args) - foo.hoge_remote_url = url - end + foo.hoge_remote_url = url end + end - context 'response.code == 200' do - let(:code) { 200 } + context 'when the response is successful' do + let(:code) { 200 } - context 'response contains headers["content-disposition"]' do - let(:file) { 'filename="foo.txt"' } - let(:headers) { { 'content-disposition' => file } } + context 'and contains Content-Disposition header' do + let(:file) { 'filename="foo.txt"' } + let(:headers) { { 'content-disposition' => file } } - it 'calls send' do - string_io = StringIO.new('') - extname = '.txt' - basename = '0123456789abcdef' + it 'assigns file' do + string_io = StringIO.new('') + extname = '.txt' + basename = '0123456789abcdef' - allow(SecureRandom).to receive(:hex).and_return(basename) - allow(StringIO).to receive(:new).with(anything).and_return(string_io) + allow(SecureRandom).to receive(:hex).and_return(basename) + allow(StringIO).to receive(:new).with(anything).and_return(string_io) - expect(foo).to receive(:public_send).with("download_#{hoge}!") + expect(foo).to receive(:public_send).with("download_#{hoge}!", url) - foo.hoge_remote_url = url + foo.hoge_remote_url = url - expect(foo).to receive(:public_send).with("#{hoge}=", string_io) - expect(foo).to receive(:public_send).with("#{hoge}_file_name=", basename + extname) + expect(foo).to receive(:public_send).with("#{hoge}=", string_io) + expect(foo).to receive(:public_send).with("#{hoge}_file_name=", basename + extname) - foo.download_hoge! - end + foo.download_hoge!(url) end end + end - context 'an error raised during the request' do - let(:request) { stub_request(:get, url).to_raise(error_class) } + context 'when an error is raised during the request' do + before do + stub_request(:get, url).to_raise(error_class) + end - error_classes = [ - HTTP::TimeoutError, - HTTP::ConnectionError, - OpenSSL::SSL::SSLError, - Paperclip::Errors::NotIdentifiedByImageMagickError, - Addressable::URI::InvalidURIError, - ] + error_classes = [ + HTTP::TimeoutError, + HTTP::ConnectionError, + OpenSSL::SSL::SSLError, + Paperclip::Errors::NotIdentifiedByImageMagickError, + Addressable::URI::InvalidURIError, + ] - error_classes.each do |error_class| - let(:error_class) { error_class } + error_classes.each do |error_class| + let(:error_class) { error_class } - it 'calls Rails.logger.debug' do - expect(Rails.logger).to receive(:debug).with(/^Error fetching remote #{hoge}: /) - foo.hoge_remote_url = url - end + it 'calls Rails.logger.debug' do + expect(Rails.logger).to receive(:debug).with(/^Error fetching remote #{hoge}: /) + foo.hoge_remote_url = url end end end -- cgit From 65506bac3f3fe233b5b7b3241020bd74eb5c9259 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 30 Jun 2020 19:19:50 +0200 Subject: Add user notes on accounts (#14148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add UserNote model * Add UI for user notes * Put comment in relationships entity * Add API to create user notes * Copy user notes to new account when receiving a Move activity * Address some of the review remarks * Replace modal by inline edition * Please CodeClimate * Button design changes * Change design again * Cancel note edition when pressing Escape * Fixes * Tweak design again * Move “Add note” item, and allow users to add notes to themselves * Rename UserNote into AccountNote, rename “comment” Relationship attribute to “note” --- .../api/v1/accounts/notes_controller.rb | 30 ++++++ app/javascript/mastodon/actions/account_notes.js | 69 ++++++++++++++ .../features/account/components/account_note.js | 103 +++++++++++++++++++++ .../mastodon/features/account/components/header.js | 11 +++ .../account/containers/account_note_container.js | 34 +++++++ .../features/account_timeline/components/header.js | 6 ++ .../containers/header_container.js | 5 + app/javascript/mastodon/reducers/account_notes.js | 44 +++++++++ app/javascript/mastodon/reducers/index.js | 2 + app/javascript/mastodon/reducers/relationships.js | 4 + app/javascript/styles/mastodon/components.scss | 62 ++++++++++++- app/models/account_note.rb | 20 ++++ app/models/concerns/account_interactions.rb | 8 ++ app/presenters/account_relationships_presenter.rb | 6 +- app/serializers/rest/relationship_serializer.rb | 6 +- app/workers/move_worker.rb | 17 ++++ config/locales/en.yml | 2 + config/routes.rb | 1 + db/migrate/20200628133322_create_account_notes.rb | 13 +++ db/schema.rb | 14 ++- spec/fabricators/account_note_fabricator.rb | 5 + spec/workers/move_worker_spec.rb | 27 ++++++ 22 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/v1/accounts/notes_controller.rb create mode 100644 app/javascript/mastodon/actions/account_notes.js create mode 100644 app/javascript/mastodon/features/account/components/account_note.js create mode 100644 app/javascript/mastodon/features/account/containers/account_note_container.js create mode 100644 app/javascript/mastodon/reducers/account_notes.js create mode 100644 app/models/account_note.rb create mode 100644 db/migrate/20200628133322_create_account_notes.rb create mode 100644 spec/fabricators/account_note_fabricator.rb (limited to 'app/models') diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb new file mode 100644 index 000000000..032e807d1 --- /dev/null +++ b/app/controllers/api/v1/accounts/notes_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::NotesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:accounts' } + before_action :require_user! + before_action :set_account + + def create + if params[:comment].blank? + AccountNote.find_by(account: current_account, target_account: @account)&.destroy + else + @note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account) + @note.comment = params[:comment] + @note.save! if @note.changed? + end + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def relationships_presenter + AccountRelationshipsPresenter.new([@account.id], current_user.account_id) + end +end diff --git a/app/javascript/mastodon/actions/account_notes.js b/app/javascript/mastodon/actions/account_notes.js new file mode 100644 index 000000000..059ed9e80 --- /dev/null +++ b/app/javascript/mastodon/actions/account_notes.js @@ -0,0 +1,69 @@ +import api from '../api'; + +export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; +export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; +export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; + +export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; +export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL'; + +export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; + +export function submitAccountNote() { + return (dispatch, getState) => { + dispatch(submitAccountNoteRequest()); + + const id = getState().getIn(['account_notes', 'edit', 'account_id']); + + api(getState).post(`/api/v1/accounts/${id}/note`, { + comment: getState().getIn(['account_notes', 'edit', 'comment']), + }).then(response => { + dispatch(submitAccountNoteSuccess(response.data)); + }).catch(error => dispatch(submitAccountNoteFail(error))); + }; +}; + +export function submitAccountNoteRequest() { + return { + type: ACCOUNT_NOTE_SUBMIT_REQUEST, + }; +}; + +export function submitAccountNoteSuccess(relationship) { + return { + type: ACCOUNT_NOTE_SUBMIT_SUCCESS, + relationship, + }; +}; + +export function submitAccountNoteFail(error) { + return { + type: ACCOUNT_NOTE_SUBMIT_FAIL, + error, + }; +}; + +export function initEditAccountNote(account) { + return (dispatch, getState) => { + const comment = getState().getIn(['relationships', account.get('id'), 'note']); + + dispatch({ + type: ACCOUNT_NOTE_INIT_EDIT, + account, + comment, + }); + }; +}; + +export function cancelAccountNote() { + return { + type: ACCOUNT_NOTE_CANCEL, + }; +}; + +export function changeAccountNoteComment(comment) { + return { + type: ACCOUNT_NOTE_CHANGE_COMMENT, + comment, + }; +}; diff --git a/app/javascript/mastodon/features/account/components/account_note.js b/app/javascript/mastodon/features/account/components/account_note.js new file mode 100644 index 000000000..832a96a6a --- /dev/null +++ b/app/javascript/mastodon/features/account/components/account_note.js @@ -0,0 +1,103 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Icon from 'mastodon/components/icon'; +import Textarea from 'react-textarea-autosize'; + +const messages = defineMessages({ + placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' }, +}); + +export default @injectIntl +class Header extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + isEditing: PropTypes.bool, + isSubmitting: PropTypes.bool, + accountNote: PropTypes.string, + onEditAccountNote: PropTypes.func.isRequired, + onCancelAccountNote: PropTypes.func.isRequired, + onSaveAccountNote: PropTypes.func.isRequired, + onChangeAccountNote: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleChangeAccountNote = (e) => { + this.props.onChangeAccountNote(e.target.value); + }; + + componentWillUnmount () { + if (this.props.isEditing) { + this.props.onCancelAccountNote(); + } + } + + handleKeyDown = e => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.props.onSaveAccountNote(); + } else if (e.keyCode === 27) { + this.props.onCancelAccountNote(); + } + } + + render () { + const { account, accountNote, isEditing, isSubmitting, intl } = this.props; + + if (!account || (!accountNote && !isEditing)) { + return null; + } + + let action_buttons = null; + if (isEditing) { + action_buttons = ( +
+ +
+ +
+ ); + } + + let note_container = null; + if (isEditing) { + note_container = ( +