From d857d0d14d273648bad5a88ce3391d6e15592039 Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 21 Aug 2019 00:18:07 +0200 Subject: Fix trending hashtags being fetched every 36 seconds instead of every hour (#11631) --- app/javascript/mastodon/features/getting_started/components/trends.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js index 1dcacc8b3..4791546e2 100644 --- a/app/javascript/mastodon/features/getting_started/components/trends.js +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -17,7 +17,7 @@ export default class Trends extends ImmutablePureComponent { componentDidMount () { this.props.fetchTrends(); - this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 3600000); } componentWillUnmount () { -- cgit From e9c3d1ef4603dfee19a59974771cb505ecfc3d29 Mon Sep 17 00:00:00 2001 From: mayaeh Date: Wed, 21 Aug 2019 19:35:40 +0900 Subject: Fix the whitelist mode display not being enabled in the admin dashboard (#11634) --- app/views/admin/dashboard/index.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 2fe1feb55..f044d9f86 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -103,7 +103,7 @@ %li = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch) %li - = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_mode) + = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled) %li = feature_hint('LDAP', @ldap_enabled) %li -- cgit From 97192d9a77c0b4b68afe50d6a94d87110a8adbcd Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 22 Aug 2019 04:17:12 +0200 Subject: Fix remote and staff-removed statuses leaving media behind for a day (#11638) The reason for unattaching media instead of removing it is to support delete & redraft functionality, but remote or staff-removed statuses will never be redrafted, so the media should be deleted immediately --- app/controllers/api/v1/statuses_controller.rb | 2 +- app/lib/activitypub/activity/delete.rb | 2 +- app/models/form/status_batch.rb | 2 +- app/services/batched_remove_status_service.rb | 2 +- app/services/remove_status_service.rb | 12 ++++++++++++ app/workers/removal_worker.rb | 4 ++-- spec/controllers/admin/reported_statuses_controller_spec.rb | 2 +- spec/controllers/admin/statuses_controller_spec.rb | 2 +- spec/models/form/status_batch_spec.rb | 4 ++-- 9 files changed, 22 insertions(+), 10 deletions(-) (limited to 'app') diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 71a505c26..39ca56482 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -53,7 +53,7 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? - RemovalWorker.perform_async(@status.id) + RemovalWorker.perform_async(@status.id, redraft: true) render json: @status, serializer: REST::StatusSerializer, source_requested: true end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 1f2b40c15..345060462 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -70,7 +70,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity end def delete_now! - RemoveStatusService.new.call(@status) + RemoveStatusService.new.call(@status, redraft: false) end def payload diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb index 933dfdaca..831d8b7c5 100644 --- a/app/models/form/status_batch.rb +++ b/app/models/form/status_batch.rb @@ -34,7 +34,7 @@ class Form::StatusBatch def delete_statuses Status.where(id: status_ids).reorder(nil).find_each do |status| - RemovalWorker.perform_async(status.id) + RemovalWorker.perform_async(status.id, redraft: false) Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) log_action :destroy, status end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 6df8d4769..3638134be 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -8,7 +8,7 @@ class BatchedRemoveStatusService < BaseService # Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones # Remove statuses from home feeds # Push delete events to streaming API for home feeds and public feeds - # @param [Status] statuses A preferably batched array of statuses + # @param [Enumerable] statuses A preferably batched array of statuses # @param [Hash] options # @option [Boolean] :skip_side_effects def call(statuses, **options) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 91c934181..685c1d4bf 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -4,6 +4,11 @@ class RemoveStatusService < BaseService include Redisable include Payloadable + # Delete a status + # @param [Status] status + # @param [Hash] options + # @option [Boolean] :redraft + # @options [Boolean] :original_removed def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @status = status @@ -24,6 +29,7 @@ class RemoveStatusService < BaseService remove_from_public remove_from_media if status.media_attachments.any? remove_from_spam_check + remove_media @status.destroy! else @@ -143,6 +149,12 @@ class RemoveStatusService < BaseService redis.publish('timeline:public:local:media', @payload) if @status.local? end + def remove_media + return if @options[:redraft] + + @status.media_attachments.destroy_all + end + def remove_from_spam_check redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id) end diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb index 19a660dd3..14423a4fb 100644 --- a/app/workers/removal_worker.rb +++ b/app/workers/removal_worker.rb @@ -3,8 +3,8 @@ class RemovalWorker include Sidekiq::Worker - def perform(status_id) - RemoveStatusService.new.call(Status.find(status_id)) + def perform(status_id, options = {}) + RemoveStatusService.new.call(Status.find(status_id), **options.symbolize_keys) rescue ActiveRecord::RecordNotFound true end diff --git a/spec/controllers/admin/reported_statuses_controller_spec.rb b/spec/controllers/admin/reported_statuses_controller_spec.rb index c358506d6..bd146b795 100644 --- a/spec/controllers/admin/reported_statuses_controller_spec.rb +++ b/spec/controllers/admin/reported_statuses_controller_spec.rb @@ -47,7 +47,7 @@ describe Admin::ReportedStatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) end end diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb index 1a08c10b7..6b06343ef 100644 --- a/spec/controllers/admin/statuses_controller_spec.rb +++ b/spec/controllers/admin/statuses_controller_spec.rb @@ -65,7 +65,7 @@ describe Admin::StatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) end end diff --git a/spec/models/form/status_batch_spec.rb b/spec/models/form/status_batch_spec.rb index 00c790a11..f9c58c90f 100644 --- a/spec/models/form/status_batch_spec.rb +++ b/spec/models/form/status_batch_spec.rb @@ -41,12 +41,12 @@ describe Form::StatusBatch do it 'call RemovalWorker' do form.save - expect(RemovalWorker).to have_received(:perform_async).with(status.id) + expect(RemovalWorker).to have_received(:perform_async).with(status.id, redraft: false) end it 'do not call RemovalWorker' do form.save - expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id) + expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, redraft: false) end end end -- cgit From 5ab1e0e738183a0ddcec140d55184351f751b22d Mon Sep 17 00:00:00 2001 From: ThibG Date: Thu, 22 Aug 2019 04:37:18 +0200 Subject: Restore hashtag suggestions from local tag history (#11632) * Restore hashtag suggestions from local tag history * Append local hashtag suggestions instead of prepending them * Do not display inaccurate usage statistics for hashtags not retrieved from API * Fixup --- app/javascript/mastodon/actions/compose.js | 2 ++ .../mastodon/components/autosuggest_hashtag.js | 6 ++--- app/javascript/mastodon/reducers/compose.js | 27 ++++++++++++++++++++-- 3 files changed, 30 insertions(+), 5 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index c27c53df0..061a36bb8 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { cancelFetchComposeSuggestionsTags(); } + dispatch(updateSuggestionTags(token)); + api(getState).get('/api/v2/search', { cancelToken: new CancelToken(cancel => { cancelFetchComposeSuggestionsTags = cancel; diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js index eabb8b178..e2f4e320d 100644 --- a/app/javascript/mastodon/components/autosuggest_hashtag.js +++ b/app/javascript/mastodon/components/autosuggest_hashtag.js @@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent { tag: PropTypes.shape({ name: PropTypes.string.isRequired, url: PropTypes.string, - history: PropTypes.array.isRequired, + history: PropTypes.array, }).isRequired, }; render () { const { tag } = this.props; - const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); return (
#{tag.name}
-
+ {tag.history !== undefined &&
}
); } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 7b0cdd5a5..268237846 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -17,6 +17,7 @@ import { COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SPOILERNESS_CHANGE, @@ -205,16 +206,36 @@ const expiresInFromExpiresAt = expires_at => { return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600; }; -const normalizeSuggestions = (state, { accounts, emojis, tags }) => { +const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { + prefix = prefix.toLowerCase(); + if (suggestions.length < 4) { + const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); + return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); + } else { + return suggestions; + } +}; + +const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { if (accounts) { return accounts.map(item => ({ id: item.id, type: 'account' })); } else if (emojis) { return emojis.map(item => ({ ...item, type: 'emoji' })); } else { - return sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))); + return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory')); } }; +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + const suggestions = state.get('suggestions').toJS(); + return state.merge({ + suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))), + suggestion_token: token, + }); +}; + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: @@ -328,6 +349,8 @@ export default function compose(state = initialState, action) { return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: return insertSuggestion(state, action.position, action.token, action.completion, action.path); + case COMPOSE_SUGGESTION_TAGS_UPDATE: + return updateSuggestionTags(state, action.token); case COMPOSE_TAG_HISTORY_UPDATE: return state.set('tagHistory', fromJS(action.tags)); case TIMELINE_DELETE: -- cgit From 282ea170782e4ce1ed5251a1b94857a512412397 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 22 Aug 2019 21:55:56 +0200 Subject: Add soft delete for statuses for instant deletes through API (#11623) * Add soft delete for statuses to allow them to appear instant * Allow reporting soft-deleted statuses and show them in the admin UI * Change index for getting an account's statuses --- Gemfile | 1 + Gemfile.lock | 3 +++ app/controllers/api/v1/reports_controller.rb | 2 +- app/controllers/api/v1/statuses/reblogs_controller.rb | 3 ++- app/controllers/api/v1/statuses_controller.rb | 1 + app/models/form/status_batch.rb | 1 + app/models/report.rb | 2 +- app/models/status.rb | 6 +++++- app/views/admin/reports/_status.html.haml | 5 ++++- app/workers/removal_worker.rb | 2 +- config/locales/en.yml | 1 + db/migrate/20190819134503_add_deleted_at_to_statuses.rb | 5 +++++ db/migrate/20190820003045_update_statuses_index.rb | 13 +++++++++++++ db/schema.rb | 5 +++-- 14 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20190819134503_add_deleted_at_to_statuses.rb create mode 100644 db/migrate/20190820003045_update_statuses_index.rb (limited to 'app') diff --git a/Gemfile b/Gemfile index 250a28a3a..86dab965a 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' +gem 'discard', '~> 1.1' gem 'doorkeeper', '~> 5.1' gem 'fast_blank', '~> 1.0' gem 'fastimage' diff --git a/Gemfile.lock b/Gemfile.lock index 1da6d73a6..b896909a4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -204,6 +204,8 @@ GEM devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.3) + discard (1.1.0) + activerecord (>= 4.2, < 7) docile (1.3.2) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) @@ -692,6 +694,7 @@ DEPENDENCIES devise (~> 4.6) devise-two-factor (~> 3.1) devise_pam_authenticatable2 (~> 9.2) + discard (~> 1.1) doorkeeper (~> 5.1) dotenv-rails (~> 2.7) fabrication (~> 2.20) diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index e182a9c6c..1b0b4b05b 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController private def reported_status_ids - reported_account.statuses.find(status_ids).pluck(:id) + reported_account.statuses.with_discarded.find(status_ids).pluck(:id) end def status_ids diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index ed4f55100..42381a37f 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController @reblogs_map = { @status.id => false } authorize status_for_destroy, :unreblog? + status_for_destroy.discard RemovalWorker.perform_async(status_for_destroy.id) render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) @@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController end def status_for_destroy - current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! + @status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! end def reblog_params diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 39ca56482..bba3c0651 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -53,6 +53,7 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? + @status.discard RemovalWorker.perform_async(@status.id, redraft: true) render json: @status, serializer: REST::StatusSerializer, source_requested: true diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb index 831d8b7c5..e09cc2594 100644 --- a/app/models/form/status_batch.rb +++ b/app/models/form/status_batch.rb @@ -34,6 +34,7 @@ class Form::StatusBatch def delete_statuses Status.where(id: status_ids).reorder(nil).find_each do |status| + status.discard RemovalWorker.perform_async(status.id, redraft: false) Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) log_action :destroy, status diff --git a/app/models/report.rb b/app/models/report.rb index 5192ceef7..1e707ff1c 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -43,7 +43,7 @@ class Report < ApplicationRecord end def statuses - Status.where(id: status_ids).includes(:account, :media_attachments, :mentions) + Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions) end def media_attachments diff --git a/app/models/status.rb b/app/models/status.rb index 0538c4e9e..9cfaddcec 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -22,15 +22,19 @@ # application_id :bigint(8) # in_reply_to_account_id :bigint(8) # poll_id :bigint(8) +# deleted_at :datetime # class Status < ApplicationRecord before_destroy :unlink_from_conversations + include Discard::Model include Paginable include Cacheable include StatusThreadingConcern + self.discard_column = :deleted_at + # If `override_timestamps` is set at creation time, Snowflake ID creation # will be based on current time instead of `created_at` attr_accessor :override_timestamps @@ -72,7 +76,7 @@ class Status < ApplicationRecord accepts_nested_attributes_for :poll - default_scope { recent } + default_scope { recent.kept } scope :recent, -> { reorder(id: :desc) } scope :remote, -> { where(local: false).where.not(uri: nil) } diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml index 9376db7ff..6facc0a56 100644 --- a/app/views/admin/reports/_status.html.haml +++ b/app/views/admin/reports/_status.html.haml @@ -16,11 +16,14 @@ - video = status.proper.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description - else - = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } + = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } .detailed-status__meta = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) + - if status.discarded? + · + %span.negative-hint= t('admin.statuses.deleted') · - if status.reblog? = fa_icon('retweet fw') diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb index 14423a4fb..2a1eaa89b 100644 --- a/app/workers/removal_worker.rb +++ b/app/workers/removal_worker.rb @@ -4,7 +4,7 @@ class RemovalWorker include Sidekiq::Worker def perform(status_id, options = {}) - RemoveStatusService.new.call(Status.find(status_id), **options.symbolize_keys) + RemoveStatusService.new.call(Status.with_discarded.find(status_id), **options.symbolize_keys) rescue ActiveRecord::RecordNotFound true end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8d267065c..a50dcb8a5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -499,6 +499,7 @@ en: delete: Delete nsfw_off: Mark as not sensitive nsfw_on: Mark as sensitive + deleted: Deleted failed_to_execute: Failed to execute media: title: Media diff --git a/db/migrate/20190819134503_add_deleted_at_to_statuses.rb b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb new file mode 100644 index 000000000..5af109097 --- /dev/null +++ b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :deleted_at, :datetime + end +end diff --git a/db/migrate/20190820003045_update_statuses_index.rb b/db/migrate/20190820003045_update_statuses_index.rb new file mode 100644 index 000000000..5c2ea1f6a --- /dev/null +++ b/db/migrate/20190820003045_update_statuses_index.rb @@ -0,0 +1,13 @@ +class UpdateStatusesIndex < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], where: 'deleted_at IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20190820 } + remove_index :statuses, name: :index_statuses_20180106 + end + + def down + safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20180106 } + remove_index :statuses, name: :index_statuses_20190820 + end +end diff --git a/db/schema.rb b/db/schema.rb index 18f615d61..afa6d724c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_08_15_225426) do +ActiveRecord::Schema.define(version: 2019_08_20_003045) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -644,7 +644,8 @@ ActiveRecord::Schema.define(version: 2019_08_15_225426) do t.bigint "application_id" t.bigint "in_reply_to_account_id" t.bigint "poll_id" - t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc } + t.datetime "deleted_at" + t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id" -- cgit From 4ef8d8b77c596680be13b26400bc2a09038928de Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 23 Aug 2019 05:05:21 +0200 Subject: Add header to trends section and change refresh rate to 15 minutes (#11641) --- .../mastodon/features/getting_started/components/trends.js | 5 ++++- app/javascript/styles/mastodon/components.scss | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js index 4791546e2..3b9a3075f 100644 --- a/app/javascript/mastodon/features/getting_started/components/trends.js +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Hashtag from 'mastodon/components/hashtag'; +import { FormattedMessage } from 'react-intl'; export default class Trends extends ImmutablePureComponent { @@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent { componentDidMount () { this.props.fetchTrends(); - this.refreshInterval = setInterval(() => this.props.fetchTrends(), 3600000); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000); } componentWillUnmount () { @@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent { return (
+

+ {trends.take(3).map(hashtag => )}
); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a65a51959..d230bb947 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2765,6 +2765,15 @@ a.account__display-name { animation: fade 150ms linear; margin-top: 10px; + h4 { + font-size: 12px; + text-transform: uppercase; + color: $darker-text-color; + padding: 10px; + font-weight: 500; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + @media screen and (max-height: 810px) { .trends__item:nth-child(3) { display: none; -- cgit From 37f612074efeb793fafbe19c90698b98b278447d Mon Sep 17 00:00:00 2001 From: ThibG Date: Fri, 23 Aug 2019 21:02:59 +0200 Subject: Fix items in StatusContent render list not all having a key (#11645) --- app/javascript/mastodon/components/status_content.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 6aa0bfcc2..c171e7a66 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent { ); } else if (this.props.onClick) { const output = [ -
+
{!!status.get('poll') && } -- cgit From 73ca0bb925cb036f824262ab292a157a40a515d0 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 23 Aug 2019 22:37:23 +0200 Subject: Add option to include reported statuses in warning e-mail (#11639) --- .../admin/account_actions_controller.rb | 4 ++-- app/javascript/styles/mailer.scss | 7 ++++++ app/mailers/user_mailer.rb | 4 +++- app/models/admin/account_action.rb | 22 +++++++++++++----- app/views/admin/account_actions/new.html.haml | 4 ++++ app/views/notification_mailer/_status.html.haml | 8 ++++++- app/views/user_mailer/warning.html.haml | 27 +++++++++++++++++++++- app/views/user_mailer/warning.text.erb | 13 +++++++++++ config/locales/en.yml | 2 ++ config/locales/simple_form.en.yml | 2 ++ spec/mailers/previews/user_mailer_preview.rb | 2 +- spec/models/admin/account_action_spec.rb | 4 ++-- 12 files changed, 85 insertions(+), 14 deletions(-) (limited to 'app') diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index a2cea461e..ea56fa0ac 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -5,7 +5,7 @@ module Admin before_action :set_account def new - @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true) + @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true) @warning_presets = AccountWarningPreset.all end @@ -30,7 +30,7 @@ module Admin end def resource_params - params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification) + params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses) end end end diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss index b4fb1d709..e25a80c04 100644 --- a/app/javascript/styles/mailer.scss +++ b/app/javascript/styles/mailer.scss @@ -457,6 +457,13 @@ h5 { .status { padding-bottom: 32px; + &--highlighted { + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + padding-bottom: 16px; + margin-bottom: 16px; + } + .status-header { td { font-size: 14px; diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 8f3a4ab3a..b41004acc 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -5,6 +5,7 @@ class UserMailer < Devise::Mailer helper :application helper :instance + helper :statuses add_template_helper RoutingHelper @@ -79,10 +80,11 @@ class UserMailer < Devise::Mailer end end - def warning(user, warning) + def warning(user, warning, status_ids = nil) @resource = user @warning = warning @instance = Rails.configuration.x.local_domain + @statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array) I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb index bdbd342fb..c7da8b52c 100644 --- a/app/models/admin/account_action.rb +++ b/app/models/admin/account_action.rb @@ -19,20 +19,25 @@ class Admin::AccountAction :report_id, :warning_preset_id - attr_reader :warning, :send_email_notification + attr_reader :warning, :send_email_notification, :include_statuses def send_email_notification=(value) @send_email_notification = ActiveModel::Type::Boolean.new.cast(value) end + def include_statuses=(value) + @include_statuses = ActiveModel::Type::Boolean.new.cast(value) + end + def save! ApplicationRecord.transaction do process_action! process_warning! end - queue_email! + process_email! process_reports! + process_queue! end def report @@ -110,7 +115,6 @@ class Admin::AccountAction authorize(target_account, :suspend?) log_action(:suspend, target_account) target_account.suspend! - queue_suspension_worker! end def text_for_warning @@ -121,16 +125,22 @@ class Admin::AccountAction Admin::SuspensionWorker.perform_async(target_account.id) end - def queue_email! - return unless warnable? + def process_queue! + queue_suspension_worker! if type == 'suspend' + end - UserMailer.warning(target_account.user, warning).deliver_later! + def process_email! + UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable? end def warnable? send_email_notification && target_account.local? end + def status_ids + @report.status_ids if @report && include_statuses + end + def warning_preset @warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present? end diff --git a/app/views/admin/account_actions/new.html.haml b/app/views/admin/account_actions/new.html.haml index 97286c8e5..20fbeef33 100644 --- a/app/views/admin/account_actions/new.html.haml +++ b/app/views/admin/account_actions/new.html.haml @@ -13,6 +13,10 @@ .fields-group = f.input :send_email_notification, as: :boolean, wrapper: :with_label + - if params[:report_id].present? + .fields-group + = f.input :include_statuses, as: :boolean, wrapper: :with_label + %hr.spacer/ - unless @warning_presets.empty? diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml index 57b5688bd..40f3aa88a 100644 --- a/app/views/notification_mailer/_status.html.haml +++ b/app/views/notification_mailer/_status.html.haml @@ -1,4 +1,5 @@ - i ||= 0 +- highlighted ||= false %table.email-table{ cellspacing: 0, cellpadding: 0, dir: 'ltr' } %tbody @@ -14,7 +15,7 @@ %table.column{ cellspacing: 0, cellpadding: 0 } %tbody %tr - %td.column-cell.padded.status + %td.column-cell.padded.status{ class: highlighted ? 'status--highlighted' : '' } %table.status-header{ cellspacing: 0, cellpadding: 0 } %tbody %tr @@ -32,5 +33,10 @@ %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } = Formatter.instance.format(status) + - if status.media_attachments.size > 0 + %p + - status.media_attachments.each do |a| + = link_to medium_url(a), medium_url(a) + %p.status-footer = link_to l(status.created_at), web_url("statuses/#{status.id}") diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml index 72ea5e5d2..030a57bb4 100644 --- a/app/views/user_mailer/warning.html.haml +++ b/app/views/user_mailer/warning.html.haml @@ -42,6 +42,14 @@ - unless @warning.text.blank? = Formatter.instance.linkify(@warning.text) + - unless @statuses.empty? + %p + %strong= t('user_mailer.warning.statuses') + +- unless @statuses.empty? + - @statuses.each_with_index do |status, i| + = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true + %table.email-table{ cellspacing: 0, cellpadding: 0 } %tbody %tr @@ -50,7 +58,7 @@ %table.content-section{ cellspacing: 0, cellpadding: 0 } %tbody %tr - %td.content-cell + %td.content-cell{ class: @statuses.empty? ? '' : 'content-start' } %table.column{ cellspacing: 0, cellpadding: 0 } %tbody %tr @@ -61,3 +69,20 @@ %td.button-primary = link_to about_more_url do %span= t 'user_mailer.warning.review_server_policies' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center + %p= t 'user_mailer.warning.get_in_touch', instance: @instance diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb index b4f2402cb..24c1f86f2 100644 --- a/app/views/user_mailer/warning.text.erb +++ b/app/views/user_mailer/warning.text.erb @@ -7,3 +7,16 @@ <% end %> <%= @warning.text %> +<% unless @statuses.empty? %> +<%= t('user_mailer.warning.statuses') %> + +<% @statuses.each do |status| %> + +<%= render 'notification_mailer/status', status: status %> +--- +<% end %> +<% else %> +--- +<% end %> + +<%= t 'user_mailer.warning.get_in_touch', instance: @instance %> diff --git a/config/locales/en.yml b/config/locales/en.yml index a50dcb8a5..ee78e4720 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1115,7 +1115,9 @@ en: disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked. silence: While your account is limited, only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you. suspend: Your account has been suspended, and all of your toots and your uploaded media files have been irreversibly removed from this server, and servers where you had followers. + get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}. review_server_policies: Review server policies + statuses: 'Specifically, for:' subject: disable: Your account %{acct} has been frozen none: Warning for %{acct} diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 98f0843d0..cfaa6e666 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -5,6 +5,7 @@ en: account_warning_preset: text: You can use toot syntax, such as URLs, hashtags and mentions admin_account_action: + include_statuses: The user will see which toots have caused the moderation action or warning send_email_notification: The user will receive an explanation of what happened with their account text_html: Optional. You can use toot syntax. You can add warning presets to save time type_html: Choose what to do with %{acct} @@ -60,6 +61,7 @@ en: account_warning_preset: text: Preset text admin_account_action: + include_statuses: Include reported toots in the e-mail send_email_notification: Notify the user per e-mail text: Custom warning type: Action diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 53c836494..ead3b3baa 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -42,6 +42,6 @@ class UserMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning def warning - UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence)) + UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id]) end end diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb index a3db60cfc..87fc28500 100644 --- a/spec/models/admin/account_action_spec.rb +++ b/spec/models/admin/account_action_spec.rb @@ -58,8 +58,8 @@ RSpec.describe Admin::AccountAction, type: :model do end.to change { Admin::ActionLog.count }.by 1 end - it 'calls queue_email!' do - expect(account_action).to receive(:queue_email!) + it 'calls process_email!' do + expect(account_action).to receive(:process_email!) subject end -- cgit From 4190e31626907059aebf32b1be66715dacb989a9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 23 Aug 2019 22:38:02 +0200 Subject: Add audio player (#11644) --- app/javascript/mastodon/components/status.js | 27 ++- .../mastodon/containers/media_container.js | 3 +- app/javascript/mastodon/features/audio/index.js | 219 +++++++++++++++++++++ .../features/status/components/detailed_status.js | 15 +- .../features/ui/components/focal_point_modal.js | 14 +- .../mastodon/features/ui/util/async-components.js | 4 + app/javascript/mastodon/features/video/index.js | 2 +- app/javascript/styles/mastodon/components.scss | 50 ++++- app/views/statuses/_detailed_status.html.haml | 6 +- app/views/statuses/_simple_status.html.haml | 6 +- package.json | 1 + yarn.lock | 5 + 12 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 app/javascript/mastodon/features/audio/index.js (limited to 'app') diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 735cab007..102551c58 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -12,7 +12,7 @@ import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent { }; renderLoadingMediaGallery () { - return
; + return
; } renderLoadingVideoPlayer () { - return
; + return
; + } + + renderLoadingAudioPlayer () { + return
; } handleOpenVideo = (media, startTime) => { @@ -348,7 +352,22 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); - } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); media = ( diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 8fddb6f54..db340032a 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -8,6 +8,7 @@ import Video from '../features/video'; import Card from '../features/status/components/card'; import Poll from 'mastodon/components/poll'; import Hashtag from 'mastodon/components/hashtag'; +import Audio from 'mastodon/features/audio'; import ModalRoot from '../components/modal_root'; import { getScrollbarWidth } from '../features/ui/components/modal_root'; import MediaModal from '../features/ui/components/media_modal'; @@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; export default class MediaContainer extends PureComponent { diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js new file mode 100644 index 000000000..4f699ce70 --- /dev/null +++ b/app/javascript/mastodon/features/audio/index.js @@ -0,0 +1,219 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import WaveSurfer from 'wavesurfer.js'; +import { defineMessages, injectIntl } from 'react-intl'; +import { formatTime } from 'mastodon/features/video'; +import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, +}); + +const arrayOf = (length, fill) => (new Array(length)).fill(fill); + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + duration: PropTypes.number, + height: PropTypes.number, + preload: PropTypes.bool, + editable: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: null, + paused: true, + muted: false, + volume: 0.5, + }; + + // hard coded in components.scss + // any way to get ::before values programatically? + + volWidth = 50; + + volOffset = 70; + + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; + } + + setVolumeRef = c => { + this.volume = c; + } + + setWaveformRef = c => { + this.waveform = c; + } + + componentDidMount () { + if (this.waveform) { + this._updateWaveform(); + } + } + + componentDidUpdate (prevProps) { + if (this.waveform && prevProps.src !== this.props.src) { + this._updateWaveform(); + } + } + + componentWillUnmount () { + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.wavesurfer = null; + } + } + + _updateWaveform () { + const { src, height, duration, preload } = this.props; + + const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color'); + const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color'); + + if (this.wavesurfer) { + this.wavesurfer.destroy(); + } + + const wavesurfer = WaveSurfer.create({ + container: this.waveform, + height, + barWidth: 3, + cursorWidth: 0, + progressColor, + waveColor, + forceDecode: true, + }); + + wavesurfer.setVolume(this.state.volume); + + if (preload) { + wavesurfer.load(src); + } else { + wavesurfer.load(src, arrayOf(1, 0.5), null, duration); + } + + wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) })); + wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) })); + wavesurfer.on('pause', () => this.setState({ paused: true })); + wavesurfer.on('play', () => this.setState({ paused: false })); + wavesurfer.on('volume', volume => this.setState({ volume })); + wavesurfer.on('mute', muted => this.setState({ muted })); + + this.wavesurfer = wavesurfer; + } + + togglePlay = () => { + if (this.state.paused) { + if (!this.props.preload) { + this.wavesurfer.createBackend(); + this.wavesurfer.createPeakCache(); + this.wavesurfer.load(this.props.src); + } + + this.wavesurfer.play(); + } else { + this.wavesurfer.pause(); + } + } + + toggleMute = () => { + this.wavesurfer.setMute(!this.state.muted); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; // x position within the element. + + if(!isNaN(x)) { + let slideamt = x; + + if (x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + + this.wavesurfer.setVolume(slideamt); + } + }, 60); + + render () { + const { height, intl, alt, editable } = this.props; + const { paused, muted, volume, currentTime } = this.state; + + const volumeWidth = muted ? 0 : volume * this.volWidth; + const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( +
+
+
+ +
+ +
+
+
+ + + +
+
+ + +
+ + + {formatTime(currentTime)} + / + {formatTime(this.state.duration || Math.floor(this.props.duration))} + +
+
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 4af157af1..980aa0655 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from '../../video'; +import Audio from '../../audio'; import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent { } if (status.get('media_attachments').size > 0) { - if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( +
)} - {['audio', 'video'].includes(media.get('type')) && ( + {media.get('type') === 'video' && (
-- cgit From f11e9d552478c45f8559391e21528c89931a9b64 Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 26 Aug 2019 18:24:10 +0200 Subject: [Glitch] Add confirmation modal when logging out from the web app Port fd3d45d38f289815bed0adc7eaa3b15894ddb8fb to glitch-soc Signed-off-by: Thibaut Girka --- .../glitch/features/compose/components/header.js | 12 ++- .../compose/containers/header_container.js | 16 +++- .../glitch/features/ui/components/link_footer.js | 93 +++++++++++++++------- app/javascript/flavours/glitch/util/log_out.js | 34 ++++++++ 4 files changed, 124 insertions(+), 31 deletions(-) create mode 100644 app/javascript/flavours/glitch/util/log_out.js (limited to 'app') diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js index 2e29084f2..8b0f540ef 100644 --- a/app/javascript/flavours/glitch/features/compose/components/header.js +++ b/app/javascript/flavours/glitch/features/compose/components/header.js @@ -53,8 +53,18 @@ class Header extends ImmutablePureComponent { showNotificationsBadge: PropTypes.bool, intl: PropTypes.object, onSettingsClick: PropTypes.func, + onLogout: PropTypes.func.isRequired, }; + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + } + render () { const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props; @@ -114,7 +124,7 @@ class Header extends ImmutablePureComponent { > diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js index ce1dea319..b4dcb4d56 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/header_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js @@ -1,6 +1,13 @@ import { openModal } from 'flavours/glitch/actions/modal'; import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; import Header from '../components/header'; +import { logOut } from 'flavours/glitch/util/log_out'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); const mapStateToProps = state => { return { @@ -16,6 +23,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ e.stopPropagation(); dispatch(openModal('SETTINGS', {})); }, + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, }); -export default connect(mapStateToProps, mapDispatchToProps)(Header); +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js index 3e724fffb..1712da83e 100644 --- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js @@ -1,36 +1,71 @@ +import { connect } from 'react-redux'; import React from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state'; import { signOutLink } from 'flavours/glitch/util/backend_links'; +import { logOut } from 'flavours/glitch/util/log_out'; +import { openModal } from 'flavours/glitch/actions/modal'; -const LinkFooter = () => ( -
-
    - {invitesEnabled &&
  • ·
  • } -
  • ·
  • -
  • ·
  • -
  • ·
  • -
  • ·
  • -
  • ·
  • -
  • ·
  • -
  • -
- -

- glitch-soc/mastodon (v{version}), - Mastodon: Mastodon }} - /> -

-
-); - -LinkFooter.propTypes = { -}; +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default @injectIntl +@connect(null, mapDispatchToProps) +class LinkFooter extends React.PureComponent { + + static propTypes = { + onLogout: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); -export default LinkFooter; + this.props.onLogout(); + + return false; + } + + render () { + return ( +
+
    + {invitesEnabled &&
  • ·
  • } +
  • ·
  • +
  • ·
  • +
  • ·
  • +
  • ·
  • +
  • ·
  • +
  • ·
  • +
  • +
+ +

+ glitch-soc/mastodon (v{version}), + Mastodon: Mastodon }} + /> +

+
+ ); + } + +}; diff --git a/app/javascript/flavours/glitch/util/log_out.js b/app/javascript/flavours/glitch/util/log_out.js new file mode 100644 index 000000000..8e1659293 --- /dev/null +++ b/app/javascript/flavours/glitch/util/log_out.js @@ -0,0 +1,34 @@ +import Rails from 'rails-ujs'; +import { signOutLink } from 'flavours/glitch/util/backend_links'; + +export const logOut = () => { + const form = document.createElement('form'); + + const methodInput = document.createElement('input'); + methodInput.setAttribute('name', '_method'); + methodInput.setAttribute('value', 'delete'); + methodInput.setAttribute('type', 'hidden'); + form.appendChild(methodInput); + + const csrfToken = Rails.csrfToken(); + const csrfParam = Rails.csrfParam(); + + if (csrfParam && csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.setAttribute('name', csrfParam); + csrfInput.setAttribute('value', csrfToken); + csrfInput.setAttribute('type', 'hidden'); + form.appendChild(csrfInput); + } + + const submitButton = document.createElement('input'); + submitButton.setAttribute('type', 'submit'); + form.appendChild(submitButton); + + form.method = 'post'; + form.action = signOutLink; + form.style.display = 'none'; + + document.body.appendChild(form); + submitButton.click(); +}; -- cgit From 04de74c992b0b7b380fdda9b42f8c69a6a098a46 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 27 Aug 2019 16:50:39 +0200 Subject: [Glitch] Add special alert for throttled requests Port 81f864d4dac349dd7cd516149d00e1cffe063edc to glitch-soc Signed-off-by: Thibaut Girka --- app/javascript/flavours/glitch/actions/alerts.js | 12 ++++++++++-- .../glitch/features/ui/containers/notifications_container.js | 2 +- app/javascript/flavours/glitch/reducers/alerts.js | 1 + app/javascript/flavours/glitch/selectors/index.js | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) (limited to 'app') diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js index ef2500e7b..cd36d8007 100644 --- a/app/javascript/flavours/glitch/actions/alerts.js +++ b/app/javascript/flavours/glitch/actions/alerts.js @@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl'; const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, + rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, + rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, }); export const ALERT_SHOW = 'ALERT_SHOW'; @@ -23,23 +25,29 @@ export function clearAlert() { }; }; -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { return { type: ALERT_SHOW, title, message, + message_values, }; }; export function showAlertForError(error) { if (error.response) { - const { data, status, statusText } = error.response; + const { data, status, statusText, headers } = error.response; if (status === 404 || status === 410) { // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + if (status === 429 && headers['x-ratelimit-reset']) { + const reset_date = new Date(headers['x-ratelimit-reset']); + return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + } + let message = statusText; let title = `${status}`; diff --git a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js index 283aa2373..82278a3be 100644 --- a/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js +++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js @@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => { const value = notification[key]; if (typeof value === 'object') { - notification[key] = intl.formatMessage(value); + notification[key] = intl.formatMessage(value, notification[`${key}_values`]); } })); diff --git a/app/javascript/flavours/glitch/reducers/alerts.js b/app/javascript/flavours/glitch/reducers/alerts.js index 50f8d30f7..ee3d54ab0 100644 --- a/app/javascript/flavours/glitch/reducers/alerts.js +++ b/app/javascript/flavours/glitch/reducers/alerts.js @@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) { key: state.size > 0 ? state.last().get('key') + 1 : 0, title: action.title, message: action.message, + message_values: action.message_values, })); case ALERT_DISMISS: return state.filterNot(item => item.get('key') === action.alert.key); diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index b414cd5e5..8ceb71d03 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -157,6 +157,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => { base.forEach(item => { arr.push({ message: item.get('message'), + message_values: item.get('message_values'), title: item.get('title'), key: item.get('key'), dismissAfter: 5000, -- cgit From 3aeaf9b897a7fff8ebb08055b06717e78e5cf914 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 23 Aug 2019 22:38:02 +0200 Subject: [Glitch] Add audio player Port 4190e31626907059aebf32b1be66715dacb989a9 to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/components/status.js | 30 ++- .../flavours/glitch/containers/media_container.js | 3 +- .../flavours/glitch/features/audio/index.js | 219 +++++++++++++++++++++ .../features/status/components/detailed_status.js | 18 +- .../features/ui/components/focal_point_modal.js | 14 +- .../flavours/glitch/features/video/index.js | 2 +- .../flavours/glitch/styles/components/media.scss | 35 ++++ .../glitch/styles/components/single_column.scss | 6 +- .../flavours/glitch/styles/components/status.scss | 9 +- .../flavours/glitch/util/async-components.js | 4 + 10 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 app/javascript/flavours/glitch/features/audio/index.js (limited to 'app') diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 5b69ac4da..47e110128 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -10,7 +10,7 @@ import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, Video } from 'flavours/glitch/util/async-components'; +import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components'; import { HotKeys } from 'react-hotkeys'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; import classNames from 'classnames'; @@ -443,11 +443,15 @@ class Status extends ImmutablePureComponent { } renderLoadingMediaGallery () { - return
; + return
; } renderLoadingVideoPlayer () { - return
; + return
; + } + + renderLoadingAudioPlayer () { + return
; } render () { @@ -561,7 +565,23 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); - } else if (['video', 'audio'].includes(attachments.getIn([0, 'type']))) { + } else if (attachments.getIn([0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); + mediaIcon = 'music'; + } else if (attachments.getIn([0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); media = ( @@ -584,7 +604,7 @@ class Status extends ImmutablePureComponent { />)} ); - mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music'; + mediaIcon = 'video-camera'; } else { // Media type is 'image' or 'gifv' media = ( diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js index 1b480658f..c1738db4d 100644 --- a/app/javascript/flavours/glitch/containers/media_container.js +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -7,6 +7,7 @@ import MediaGallery from 'flavours/glitch/components/media_gallery'; import Video from 'flavours/glitch/features/video'; import Card from 'flavours/glitch/features/status/components/card'; import Poll from 'flavours/glitch/components/poll'; +import Audio from 'flavours/glitch/features/audio'; import ModalRoot from 'flavours/glitch/components/modal_root'; import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; import { List as ImmutableList, fromJS } from 'immutable'; @@ -14,7 +15,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Audio }; export default class MediaContainer extends PureComponent { diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js new file mode 100644 index 000000000..1daf56bc1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -0,0 +1,219 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import WaveSurfer from 'wavesurfer.js'; +import { defineMessages, injectIntl } from 'react-intl'; +import { formatTime } from 'flavours/glitch/features/video'; +import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, +}); + +const arrayOf = (length, fill) => (new Array(length)).fill(fill); + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + duration: PropTypes.number, + height: PropTypes.number, + preload: PropTypes.bool, + editable: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: null, + paused: true, + muted: false, + volume: 0.5, + }; + + // hard coded in components.scss + // any way to get ::before values programatically? + + volWidth = 50; + + volOffset = 70; + + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; + } + + setVolumeRef = c => { + this.volume = c; + } + + setWaveformRef = c => { + this.waveform = c; + } + + componentDidMount () { + if (this.waveform) { + this._updateWaveform(); + } + } + + componentDidUpdate (prevProps) { + if (this.waveform && prevProps.src !== this.props.src) { + this._updateWaveform(); + } + } + + componentWillUnmount () { + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.wavesurfer = null; + } + } + + _updateWaveform () { + const { src, height, duration, preload } = this.props; + + const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color'); + const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color'); + + if (this.wavesurfer) { + this.wavesurfer.destroy(); + } + + const wavesurfer = WaveSurfer.create({ + container: this.waveform, + height, + barWidth: 3, + cursorWidth: 0, + progressColor, + waveColor, + forceDecode: true, + }); + + wavesurfer.setVolume(this.state.volume); + + if (preload) { + wavesurfer.load(src); + } else { + wavesurfer.load(src, arrayOf(1, 0.5), null, duration); + } + + wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) })); + wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) })); + wavesurfer.on('pause', () => this.setState({ paused: true })); + wavesurfer.on('play', () => this.setState({ paused: false })); + wavesurfer.on('volume', volume => this.setState({ volume })); + wavesurfer.on('mute', muted => this.setState({ muted })); + + this.wavesurfer = wavesurfer; + } + + togglePlay = () => { + if (this.state.paused) { + if (!this.props.preload) { + this.wavesurfer.createBackend(); + this.wavesurfer.createPeakCache(); + this.wavesurfer.load(this.props.src); + } + + this.wavesurfer.play(); + } else { + this.wavesurfer.pause(); + } + } + + toggleMute = () => { + this.wavesurfer.setMute(!this.state.muted); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; // x position within the element. + + if(!isNaN(x)) { + let slideamt = x; + + if (x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + + this.wavesurfer.setVolume(slideamt); + } + }, 60); + + render () { + const { height, intl, alt, editable } = this.props; + const { paused, muted, volume, currentTime } = this.state; + + const volumeWidth = muted ? 0 : volume * this.volWidth; + const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( +
+
+
+ +
+ +
+
+
+ + + +
+
+ + +
+ + + {formatTime(currentTime)} + / + {formatTime(this.state.duration || Math.floor(this.props.duration))} + +
+
+
+
+ ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 873ea35fb..b676410d6 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import classNames from 'classnames'; @@ -131,7 +132,20 @@ export default class DetailedStatus extends ImmutablePureComponent { } else if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = ; - } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( +