Merge tag 'v3.1.1' into instance_only_statuses
This commit is contained in:
@ -7,6 +7,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
|
||||
before_action :skip_unknown_actor_delete
|
||||
before_action :require_signature!
|
||||
skip_before_action :authenticate_user!
|
||||
|
||||
def create
|
||||
upgrade_account
|
||||
|
@ -109,21 +109,7 @@ module Admin
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(
|
||||
:local,
|
||||
:remote,
|
||||
:by_domain,
|
||||
:active,
|
||||
:pending,
|
||||
:disabled,
|
||||
:silenced,
|
||||
:suspended,
|
||||
:username,
|
||||
:display_name,
|
||||
:email,
|
||||
:ip,
|
||||
:staff
|
||||
)
|
||||
params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
88
app/controllers/admin/announcements_controller.rb
Normal file
88
app/controllers/admin/announcements_controller.rb
Normal file
@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::AnnouncementsController < Admin::BaseController
|
||||
before_action :set_announcements, only: :index
|
||||
before_action :set_announcement, except: [:index, :new, :create]
|
||||
|
||||
def index
|
||||
authorize :announcement, :index?
|
||||
end
|
||||
|
||||
def new
|
||||
authorize :announcement, :create?
|
||||
|
||||
@announcement = Announcement.new
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :announcement, :create?
|
||||
|
||||
@announcement = Announcement.new(resource_params)
|
||||
|
||||
if @announcement.save
|
||||
PublishScheduledAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
|
||||
log_action :create, @announcement
|
||||
redirect_to admin_announcements_path, notice: @announcement.published? ? I18n.t('admin.announcements.published_msg') : I18n.t('admin.announcements.scheduled_msg')
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize :announcement, :update?
|
||||
end
|
||||
|
||||
def update
|
||||
authorize :announcement, :update?
|
||||
|
||||
if @announcement.update(resource_params)
|
||||
PublishScheduledAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
|
||||
log_action :update, @announcement
|
||||
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.updated_msg')
|
||||
else
|
||||
render :edit
|
||||
end
|
||||
end
|
||||
|
||||
def publish
|
||||
authorize :announcement, :update?
|
||||
@announcement.publish!
|
||||
PublishScheduledAnnouncementWorker.perform_async(@announcement.id)
|
||||
log_action :update, @announcement
|
||||
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.published_msg')
|
||||
end
|
||||
|
||||
def unpublish
|
||||
authorize :announcement, :update?
|
||||
@announcement.unpublish!
|
||||
UnpublishAnnouncementWorker.perform_async(@announcement.id)
|
||||
log_action :update, @announcement
|
||||
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.unpublished_msg')
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize :announcement, :destroy?
|
||||
@announcement.destroy!
|
||||
UnpublishAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
|
||||
log_action :destroy, @announcement
|
||||
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.destroyed_msg')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_announcements
|
||||
@announcements = AnnouncementFilter.new(filter_params).results.page(params[:page])
|
||||
end
|
||||
|
||||
def set_announcement
|
||||
@announcement = Announcement.find(params[:id])
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.slice(*AnnouncementFilter::KEYS).permit(*AnnouncementFilter::KEYS)
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day)
|
||||
end
|
||||
end
|
@ -2,10 +2,6 @@
|
||||
|
||||
module Admin
|
||||
class CustomEmojisController < BaseController
|
||||
include ObfuscateFilename
|
||||
|
||||
obfuscate_filename [:custom_emoji, :image]
|
||||
|
||||
def index
|
||||
authorize :custom_emoji, :index?
|
||||
|
||||
@ -52,7 +48,7 @@ module Admin
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.slice(:local, :remote, :by_domain, :shortcode, :page).permit(:local, :remote, :by_domain, :shortcode, :page)
|
||||
params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS)
|
||||
end
|
||||
|
||||
def action_from_button
|
||||
|
@ -1,18 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class FollowersController < BaseController
|
||||
before_action :set_account
|
||||
|
||||
PER_PAGE = 40
|
||||
|
||||
def index
|
||||
authorize :account, :index?
|
||||
@followers = @account.followers.local.recent.page(params[:page]).per(PER_PAGE)
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
end
|
@ -62,7 +62,7 @@ module Admin
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(:limited, :by_domain)
|
||||
params.slice(*InstanceFilter::KEYS).permit(*InstanceFilter::KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -47,7 +47,7 @@ module Admin
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(:available, :expired)
|
||||
params.slice(*InviteFilter::KEYS).permit(*InviteFilter::KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
25
app/controllers/admin/relationships_controller.rb
Normal file
25
app/controllers/admin/relationships_controller.rb
Normal file
@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class RelationshipsController < BaseController
|
||||
before_action :set_account
|
||||
|
||||
PER_PAGE = 40
|
||||
|
||||
def index
|
||||
authorize :account, :index?
|
||||
|
||||
@accounts = RelationshipFilter.new(@account, filter_params).results.page(params[:page]).per(PER_PAGE)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.slice(*RelationshipFilter::KEYS).permit(*RelationshipFilter::KEYS)
|
||||
end
|
||||
end
|
||||
end
|
@ -52,11 +52,7 @@ module Admin
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(
|
||||
:account_id,
|
||||
:resolved,
|
||||
:target_account_id
|
||||
)
|
||||
params.slice(*ReportFilter::KEYS).permit(*ReportFilter::KEYS)
|
||||
end
|
||||
|
||||
def set_report
|
||||
|
@ -73,7 +73,7 @@ module Admin
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.slice(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name).permit(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name)
|
||||
params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
|
||||
end
|
||||
|
||||
def tag_params
|
||||
|
@ -20,6 +20,10 @@ class Api::BaseController < ApplicationController
|
||||
render json: { error: e.to_s }, status: 422
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotUnique do
|
||||
render json: { error: 'Duplicate record' }, status: 422
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do
|
||||
render json: { error: 'Record not found' }, status: 404
|
||||
end
|
||||
@ -81,7 +85,7 @@ class Api::BaseController < ApplicationController
|
||||
end
|
||||
|
||||
def require_authenticated_user!
|
||||
render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user
|
||||
render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user
|
||||
end
|
||||
|
||||
def require_user!
|
||||
|
@ -1,15 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::OEmbedController < Api::BaseController
|
||||
respond_to :json
|
||||
skip_before_action :require_authenticated_user!
|
||||
|
||||
before_action :set_status
|
||||
before_action :require_public_status!
|
||||
|
||||
def show
|
||||
@status = status_finder.status
|
||||
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_status
|
||||
@status = status_finder.status
|
||||
end
|
||||
|
||||
def require_public_status!
|
||||
not_found if @status.hidden?
|
||||
end
|
||||
|
||||
def status_finder
|
||||
StatusFinder.new(params[:url])
|
||||
end
|
||||
|
@ -3,6 +3,8 @@
|
||||
class Api::ProofsController < Api::BaseController
|
||||
include AccountOwnedConcern
|
||||
|
||||
skip_before_action :require_authenticated_user!
|
||||
|
||||
before_action :set_provider
|
||||
|
||||
def index
|
||||
|
@ -25,7 +25,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
||||
end
|
||||
|
||||
def user_settings_params
|
||||
return nil unless params.key?(:source)
|
||||
return nil if params[:source].blank?
|
||||
|
||||
source_params = params.require(:source)
|
||||
|
||||
|
@ -21,11 +21,13 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
|
||||
def load_accounts
|
||||
return [] if hide_results?
|
||||
|
||||
default_accounts.merge(paginated_follows).to_a
|
||||
scope = default_accounts
|
||||
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
|
||||
scope.merge(paginated_follows).to_a
|
||||
end
|
||||
|
||||
def hide_results?
|
||||
(@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account))
|
||||
(@account.user_hides_network? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
|
||||
end
|
||||
|
||||
def default_accounts
|
||||
|
@ -21,11 +21,13 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
|
||||
def load_accounts
|
||||
return [] if hide_results?
|
||||
|
||||
default_accounts.merge(paginated_follows).to_a
|
||||
scope = default_accounts
|
||||
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
|
||||
scope.merge(paginated_follows).to_a
|
||||
end
|
||||
|
||||
def hide_results?
|
||||
(@account.user_hides_network? && current_account.id != @account.id) || (current_account && @account.blocking?(current_account))
|
||||
(@account.user_hides_network? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
|
||||
end
|
||||
|
||||
def default_accounts
|
||||
|
29
app/controllers/api/v1/announcements/reactions_controller.rb
Normal file
29
app/controllers/api/v1/announcements/reactions_controller.rb
Normal file
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Announcements::ReactionsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
|
||||
before_action :require_user!
|
||||
|
||||
before_action :set_announcement
|
||||
before_action :set_reaction, except: :update
|
||||
|
||||
def update
|
||||
@announcement.announcement_reactions.create!(account: current_account, name: params[:id])
|
||||
render_empty
|
||||
end
|
||||
|
||||
def destroy
|
||||
@reaction.destroy!
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_reaction
|
||||
@reaction = @announcement.announcement_reactions.where(account: current_account).find_by!(name: params[:id])
|
||||
end
|
||||
|
||||
def set_announcement
|
||||
@announcement = Announcement.published.find(params[:announcement_id])
|
||||
end
|
||||
end
|
29
app/controllers/api/v1/announcements_controller.rb
Normal file
29
app/controllers/api/v1/announcements_controller.rb
Normal file
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::AnnouncementsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :dismiss
|
||||
before_action :require_user!
|
||||
before_action :set_announcements, only: :index
|
||||
before_action :set_announcement, except: :index
|
||||
|
||||
def index
|
||||
render json: @announcements, each_serializer: REST::AnnouncementSerializer
|
||||
end
|
||||
|
||||
def dismiss
|
||||
AnnouncementMute.create!(account: current_account, announcement: @announcement)
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_announcements
|
||||
@announcements = begin
|
||||
Announcement.published.chronological
|
||||
end
|
||||
end
|
||||
|
||||
def set_announcement
|
||||
@announcement = Announcement.published.find(params[:id])
|
||||
end
|
||||
end
|
66
app/controllers/api/v1/bookmarks_controller.rb
Normal file
66
app/controllers/api/v1/bookmarks_controller.rb
Normal file
@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::BookmarksController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:bookmarks' }
|
||||
before_action :require_user!
|
||||
after_action :insert_pagination_headers
|
||||
|
||||
respond_to :json
|
||||
|
||||
def index
|
||||
@statuses = load_statuses
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_statuses
|
||||
cached_bookmarks
|
||||
end
|
||||
|
||||
def cached_bookmarks
|
||||
cache_collection(
|
||||
Status.reorder(nil).joins(:bookmarks).merge(results),
|
||||
Status
|
||||
)
|
||||
end
|
||||
|
||||
def results
|
||||
@_results ||= account_bookmarks.paginate_by_id(
|
||||
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||
params_slice(:max_id, :since_id, :min_id)
|
||||
)
|
||||
end
|
||||
|
||||
def account_bookmarks
|
||||
current_account.bookmarks
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty?
|
||||
end
|
||||
|
||||
def pagination_max_id
|
||||
results.last.id
|
||||
end
|
||||
|
||||
def pagination_since_id
|
||||
results.first.id
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
results.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.slice(:limit).permit(:limit).merge(core_params)
|
||||
end
|
||||
end
|
@ -4,9 +4,6 @@ class Api::V1::MediaController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:media' }
|
||||
before_action :require_user!
|
||||
|
||||
include ObfuscateFilename
|
||||
obfuscate_filename :file
|
||||
|
||||
respond_to :json
|
||||
|
||||
def create
|
||||
|
@ -51,6 +51,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
||||
|
||||
def data_params
|
||||
return {} if params[:data].blank?
|
||||
params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll])
|
||||
params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll])
|
||||
end
|
||||
end
|
||||
|
39
app/controllers/api/v1/statuses/bookmarks_controller.rb
Normal file
39
app/controllers/api/v1/statuses/bookmarks_controller.rb
Normal file
@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::BookmarksController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' }
|
||||
before_action :require_user!
|
||||
|
||||
respond_to :json
|
||||
|
||||
def create
|
||||
@status = bookmarked_status
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
@status = requested_status
|
||||
@bookmarks_map = { @status.id => false }
|
||||
|
||||
bookmark = Bookmark.find_by!(account: current_user.account, status: @status)
|
||||
bookmark.destroy!
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, bookmarks_map: @bookmarks_map)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bookmarked_status
|
||||
authorize_with current_user.account, requested_status, :show?
|
||||
|
||||
bookmark = Bookmark.find_or_create_by!(account: current_user.account, status: requested_status)
|
||||
|
||||
bookmark.status.reload
|
||||
end
|
||||
|
||||
def requested_status
|
||||
Status.find(params[:status_id])
|
||||
end
|
||||
end
|
@ -17,7 +17,9 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def load_accounts
|
||||
default_accounts.merge(paginated_favourites).to_a
|
||||
scope = default_accounts
|
||||
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
|
||||
scope.merge(paginated_favourites).to_a
|
||||
end
|
||||
|
||||
def default_accounts
|
||||
|
@ -17,7 +17,9 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def load_accounts
|
||||
default_accounts.merge(paginated_statuses).to_a
|
||||
scope = default_accounts
|
||||
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
|
||||
scope.merge(paginated_statuses).to_a
|
||||
end
|
||||
|
||||
def default_accounts
|
||||
|
@ -7,15 +7,21 @@ class Api::Web::EmbedsController < Api::Web::BaseController
|
||||
|
||||
def create
|
||||
status = StatusFinder.new(params[:url]).status
|
||||
|
||||
return not_found if status.hidden?
|
||||
|
||||
render json: status, serializer: OEmbedSerializer, width: 400
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
oembed = FetchOEmbedService.new.call(params[:url])
|
||||
oembed[:html] = Formatter.instance.sanitize(oembed[:html], Sanitize::Config::MASTODON_OEMBED) if oembed[:html].present?
|
||||
|
||||
if oembed
|
||||
render json: oembed
|
||||
else
|
||||
render json: {}, status: :not_found
|
||||
return not_found if oembed.nil?
|
||||
|
||||
begin
|
||||
oembed[:html] = Formatter.instance.sanitize(oembed[:html], Sanitize::Config::MASTODON_OEMBED)
|
||||
rescue ArgumentError
|
||||
return not_found
|
||||
end
|
||||
|
||||
render json: oembed
|
||||
end
|
||||
end
|
||||
|
@ -19,6 +19,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
data = {
|
||||
alerts: {
|
||||
follow: alerts_enabled,
|
||||
follow_request: false,
|
||||
favourite: alerts_enabled,
|
||||
reblog: alerts_enabled,
|
||||
mention: alerts_enabled,
|
||||
@ -58,6 +59,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||
end
|
||||
|
||||
def data_params
|
||||
@data_params ||= params.require(:data).permit(alerts: [:follow, :favourite, :reblog, :mention, :poll])
|
||||
@data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll])
|
||||
end
|
||||
end
|
||||
|
@ -24,6 +24,7 @@ class ApplicationController < ActionController::Base
|
||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
||||
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
||||
rescue_from ActionController::ParameterMissing, with: :bad_request
|
||||
rescue_from Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
|
||||
@ -137,8 +138,8 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
def respond_with_error(code)
|
||||
respond_to do |format|
|
||||
format.any { head code }
|
||||
format.html { render "errors/#{code}", layout: 'error', status: code }
|
||||
format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
|
||||
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -6,6 +6,12 @@ class Auth::PasswordsController < Devise::PasswordsController
|
||||
|
||||
layout 'auth'
|
||||
|
||||
def update
|
||||
super do |resource|
|
||||
resource.session_activations.destroy_all if resource.errors.empty?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_validity_of_reset_password_token
|
||||
|
@ -10,6 +10,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
before_action :set_instance_presenter, only: [:new, :create, :update]
|
||||
before_action :set_body_classes, only: [:new, :create, :edit, :update]
|
||||
before_action :require_not_suspended!, only: [:update]
|
||||
before_action :set_cache_headers, only: [:edit, :update]
|
||||
|
||||
skip_before_action :require_functional!, only: [:edit, :update]
|
||||
|
||||
@ -21,10 +22,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
not_found
|
||||
end
|
||||
|
||||
def update
|
||||
super do |resource|
|
||||
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def update_resource(resource, params)
|
||||
params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
@ -109,4 +117,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
def require_not_suspended!
|
||||
forbidden if current_account.suspended?
|
||||
end
|
||||
|
||||
def set_cache_headers
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
|
||||
end
|
||||
end
|
||||
|
@ -1,16 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ObfuscateFilename
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def obfuscate_filename(path)
|
||||
before_action do
|
||||
file = params.dig(*path)
|
||||
next if file.nil?
|
||||
|
||||
file.original_filename = SecureRandom.hex(8) + File.extname(file.original_filename)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -160,6 +160,8 @@ module SignatureVerification
|
||||
account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) }
|
||||
account
|
||||
end
|
||||
rescue Mastodon::HostValidationError
|
||||
nil
|
||||
end
|
||||
|
||||
def stoplight_wrap_request(&block)
|
||||
|
@ -1,10 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FiltersController < ApplicationController
|
||||
include Authorization
|
||||
|
||||
layout 'admin'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_filters, only: :index
|
||||
before_action :set_filter, only: [:edit, :update, :destroy]
|
||||
before_action :set_body_classes
|
||||
|
@ -18,7 +18,6 @@ class FollowerAccountsController < ApplicationController
|
||||
next if @account.user_hides_network?
|
||||
|
||||
follows
|
||||
@relationships = AccountRelationshipsPresenter.new(follows.map(&:account_id), current_user.account_id) if user_signed_in?
|
||||
end
|
||||
|
||||
format.json do
|
||||
@ -37,7 +36,11 @@ class FollowerAccountsController < ApplicationController
|
||||
private
|
||||
|
||||
def follows
|
||||
@follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
|
||||
return @follows if defined?(@follows)
|
||||
|
||||
scope = Follow.where(target_account: @account)
|
||||
scope = scope.where.not(account_id: current_account.excluded_from_timeline_account_ids) if user_signed_in?
|
||||
@follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
|
||||
end
|
||||
|
||||
def page_requested?
|
||||
|
@ -18,7 +18,6 @@ class FollowingAccountsController < ApplicationController
|
||||
next if @account.user_hides_network?
|
||||
|
||||
follows
|
||||
@relationships = AccountRelationshipsPresenter.new(follows.map(&:target_account_id), current_user.account_id) if user_signed_in?
|
||||
end
|
||||
|
||||
format.json do
|
||||
@ -37,7 +36,11 @@ class FollowingAccountsController < ApplicationController
|
||||
private
|
||||
|
||||
def follows
|
||||
@follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
|
||||
return @follows if defined?(@follows)
|
||||
|
||||
scope = Follow.where(account: @account)
|
||||
scope = scope.where.not(target_account_id: current_account.excluded_from_timeline_account_ids) if user_signed_in?
|
||||
@follows = scope.recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
|
||||
end
|
||||
|
||||
def page_requested?
|
||||
|
@ -5,6 +5,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
|
||||
before_action :store_current_location
|
||||
before_action :authenticate_resource_owner!
|
||||
before_action :set_cache_headers
|
||||
|
||||
include Localized
|
||||
|
||||
@ -27,4 +28,8 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
def truthy_param?(key)
|
||||
ActiveModel::Type::Boolean.new.cast(params[key])
|
||||
end
|
||||
|
||||
def set_cache_headers
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
|
||||
end
|
||||
end
|
||||
|
@ -19,53 +19,13 @@ class RelationshipsController < ApplicationController
|
||||
rescue ActionController::ParameterMissing
|
||||
# Do nothing
|
||||
ensure
|
||||
redirect_to relationships_path(current_params)
|
||||
redirect_to relationships_path(filter_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_accounts
|
||||
@accounts = relationships_scope.page(params[:page]).per(40)
|
||||
end
|
||||
|
||||
def relationships_scope
|
||||
scope = begin
|
||||
if following_relationship?
|
||||
current_account.following.eager_load(:account_stat).reorder(nil)
|
||||
else
|
||||
current_account.followers.eager_load(:account_stat).reorder(nil)
|
||||
end
|
||||
end
|
||||
|
||||
scope.merge!(Follow.recent) if params[:order].blank? || params[:order] == 'recent'
|
||||
scope.merge!(Account.by_recent_status) if params[:order] == 'active'
|
||||
scope.merge!(mutual_relationship_scope) if mutual_relationship?
|
||||
scope.merge!(moved_account_scope) if params[:status] == 'moved'
|
||||
scope.merge!(primary_account_scope) if params[:status] == 'primary'
|
||||
scope.merge!(by_domain_scope) if params[:by_domain].present?
|
||||
scope.merge!(dormant_account_scope) if params[:activity] == 'dormant'
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def mutual_relationship_scope
|
||||
Account.where(id: current_account.following)
|
||||
end
|
||||
|
||||
def moved_account_scope
|
||||
Account.where.not(moved_to_account_id: nil)
|
||||
end
|
||||
|
||||
def primary_account_scope
|
||||
Account.where(moved_to_account_id: nil)
|
||||
end
|
||||
|
||||
def dormant_account_scope
|
||||
AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago)))
|
||||
end
|
||||
|
||||
def by_domain_scope
|
||||
Account.where(domain: params[:by_domain])
|
||||
@accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40)
|
||||
end
|
||||
|
||||
def form_account_batch_params
|
||||
@ -84,8 +44,8 @@ class RelationshipsController < ApplicationController
|
||||
params[:relationship] == 'followed_by'
|
||||
end
|
||||
|
||||
def current_params
|
||||
params.slice(:page, :status, :relationship, :by_domain, :activity, :order).permit(:page, :status, :relationship, :by_domain, :activity, :order)
|
||||
def filter_params
|
||||
params.slice(:page, *RelationshipFilter::KEYS).permit(:page, *RelationshipFilter::KEYS)
|
||||
end
|
||||
|
||||
def action_from_button
|
||||
|
@ -2,10 +2,15 @@
|
||||
|
||||
class Settings::BaseController < ApplicationController
|
||||
before_action :set_body_classes
|
||||
before_action :set_cache_headers
|
||||
|
||||
private
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'admin'
|
||||
end
|
||||
|
||||
def set_cache_headers
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
|
||||
end
|
||||
end
|
||||
|
@ -58,6 +58,7 @@ class Settings::PreferencesController < Settings::BaseController
|
||||
:setting_use_blurhash,
|
||||
:setting_use_pending_items,
|
||||
:setting_trends,
|
||||
:setting_crop_images,
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||
)
|
||||
|
@ -1,16 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Settings::ProfilesController < Settings::BaseController
|
||||
include ObfuscateFilename
|
||||
|
||||
layout 'admin'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_account
|
||||
|
||||
obfuscate_filename [:account, :avatar]
|
||||
obfuscate_filename [:account, :header]
|
||||
|
||||
def show
|
||||
@account.build_fields
|
||||
end
|
||||
|
@ -46,7 +46,7 @@ class StatusesController < ApplicationController
|
||||
end
|
||||
|
||||
def embed
|
||||
raise ActiveRecord::RecordNotFound if @status.hidden?
|
||||
return not_found if @status.hidden?
|
||||
|
||||
expires_in 180, public: true
|
||||
response.headers['X-Frame-Options'] = 'ALLOWALL'
|
||||
@ -68,7 +68,7 @@ class StatusesController < ApplicationController
|
||||
@status = @account.statuses.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
raise ActiveRecord::RecordNotFound
|
||||
not_found
|
||||
end
|
||||
|
||||
def set_instance_presenter
|
||||
|
@ -24,7 +24,7 @@ class TagsController < ApplicationController
|
||||
format.rss do
|
||||
expires_in 0, public: true
|
||||
|
||||
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none)).limit(PAGE_SIZE)
|
||||
@statuses = HashtagQueryService.new.call(@tag, filter_params).limit(PAGE_SIZE)
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
|
||||
render xml: RSS::TagSerializer.render(@tag, @statuses)
|
||||
@ -33,7 +33,7 @@ class TagsController < ApplicationController
|
||||
format.json do
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
|
||||
@statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
|
||||
@statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
|
||||
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
@ -57,10 +57,14 @@ class TagsController < ApplicationController
|
||||
|
||||
def collection_presenter
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: tag_url(@tag, params.slice(:any, :all, :none)),
|
||||
id: tag_url(@tag, filter_params),
|
||||
type: :ordered,
|
||||
size: @tag.statuses.count,
|
||||
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
|
||||
)
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.slice(:any, :all, :none).permit(:any, :all, :none)
|
||||
end
|
||||
end
|
||||
|
@ -8,12 +8,8 @@ module WellKnown
|
||||
|
||||
def show
|
||||
@webfinger_template = "#{webfinger_url}?resource={uri}"
|
||||
|
||||
respond_to do |format|
|
||||
format.xml { render content_type: 'application/xrd+xml' }
|
||||
end
|
||||
|
||||
expires_in 3.days, public: true
|
||||
render content_type: 'application/xrd+xml', formats: [:xml]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
108
app/helpers/accounts_helper.rb
Normal file
108
app/helpers/accounts_helper.rb
Normal file
@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountsHelper
|
||||
def display_name(account, **options)
|
||||
if options[:custom_emojify]
|
||||
Formatter.instance.format_display_name(account, **options)
|
||||
else
|
||||
account.display_name.presence || account.username
|
||||
end
|
||||
end
|
||||
|
||||
def acct(account)
|
||||
if account.local?
|
||||
"@#{account.acct}@#{site_hostname}"
|
||||
else
|
||||
"@#{account.pretty_acct}"
|
||||
end
|
||||
end
|
||||
|
||||
def account_action_button(account)
|
||||
if user_signed_in?
|
||||
if account.id == current_user.account_id
|
||||
link_to settings_profile_url, class: 'button logo-button' do
|
||||
safe_join([svg_logo, t('settings.edit_profile')])
|
||||
end
|
||||
elsif current_account.following?(account) || current_account.requested?(account)
|
||||
link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do
|
||||
safe_join([svg_logo, t('accounts.unfollow')])
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do
|
||||
safe_join([svg_logo, t('accounts.follow')])
|
||||
end
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do
|
||||
safe_join([svg_logo, t('accounts.follow')])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def minimal_account_action_button(account)
|
||||
if user_signed_in?
|
||||
return if account.id == current_user.account_id
|
||||
|
||||
if current_account.following?(account) || current_account.requested?(account)
|
||||
link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
|
||||
fa_icon('user-times fw')
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
|
||||
fa_icon('user-plus fw')
|
||||
end
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
|
||||
fa_icon('user-plus fw')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def account_badge(account, all: false)
|
||||
if account.bot?
|
||||
content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
|
||||
elsif account.group?
|
||||
content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles')
|
||||
elsif (Setting.show_staff_badge && account.user_staff?) || all
|
||||
content_tag(:div, class: 'roles') do
|
||||
if all && !account.user_staff?
|
||||
content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role')
|
||||
elsif account.user_admin?
|
||||
content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin')
|
||||
elsif account.user_moderator?
|
||||
content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def account_description(account)
|
||||
prepend_str = [
|
||||
[
|
||||
number_to_human(account.statuses_count, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.posts', count: account.statuses_count),
|
||||
].join(' '),
|
||||
|
||||
[
|
||||
number_to_human(account.following_count, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.following', count: account.following_count),
|
||||
].join(' '),
|
||||
|
||||
[
|
||||
number_to_human(account.followers_count, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.followers', count: account.followers_count),
|
||||
].join(' '),
|
||||
].join(', ')
|
||||
|
||||
[prepend_str, account.note].join(' · ')
|
||||
end
|
||||
|
||||
def svg_logo
|
||||
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
|
||||
end
|
||||
|
||||
def svg_logo_full
|
||||
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo-full'), 'viewBox' => '0 0 713.35878 175.8678')
|
||||
end
|
||||
end
|
@ -22,6 +22,8 @@ module Admin::ActionLogsHelper
|
||||
log.recorded_changes.slice('severity', 'reject_media')
|
||||
elsif log.target_type == 'Status' && log.action == :update
|
||||
log.recorded_changes.slice('sensitive')
|
||||
elsif log.target_type == 'Announcement' && log.action == :update
|
||||
log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day')
|
||||
end
|
||||
end
|
||||
|
||||
@ -44,12 +46,16 @@ module Admin::ActionLogsHelper
|
||||
'flag'
|
||||
when 'DomainBlock'
|
||||
'lock'
|
||||
when 'DomainAllow'
|
||||
'plus-circle'
|
||||
when 'EmailDomainBlock'
|
||||
'envelope'
|
||||
when 'Status'
|
||||
'pencil'
|
||||
when 'AccountWarning'
|
||||
'warning'
|
||||
when 'Announcement'
|
||||
'bullhorn'
|
||||
end
|
||||
end
|
||||
|
||||
@ -86,12 +92,14 @@ module Admin::ActionLogsHelper
|
||||
record.shortcode
|
||||
when 'Report'
|
||||
link_to "##{record.id}", admin_report_path(record)
|
||||
when 'DomainBlock', 'EmailDomainBlock'
|
||||
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock'
|
||||
link_to record.domain, "https://#{record.domain}"
|
||||
when 'Status'
|
||||
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
|
||||
when 'AccountWarning'
|
||||
link_to record.target_account.acct, admin_account_path(record.target_account_id)
|
||||
when 'Announcement'
|
||||
link_to "##{record.id}", edit_admin_announcement_path(record.id)
|
||||
end
|
||||
end
|
||||
|
||||
@ -99,7 +107,7 @@ module Admin::ActionLogsHelper
|
||||
case type
|
||||
when 'CustomEmoji'
|
||||
attributes['shortcode']
|
||||
when 'DomainBlock', 'EmailDomainBlock'
|
||||
when 'DomainBlock', 'DomainAllow', 'EmailDomainBlock'
|
||||
link_to attributes['domain'], "https://#{attributes['domain']}"
|
||||
when 'Status'
|
||||
tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
|
||||
@ -109,6 +117,8 @@ module Admin::ActionLogsHelper
|
||||
else
|
||||
I18n.t('admin.action_logs.deleted_status')
|
||||
end
|
||||
when 'Announcement'
|
||||
"##{attributes['id']}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
11
app/helpers/admin/announcements_helper.rb
Normal file
11
app/helpers/admin/announcements_helper.rb
Normal file
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin::AnnouncementsHelper
|
||||
def time_range(announcement)
|
||||
if announcement.all_day?
|
||||
safe_join([l(announcement.starts_at.to_date), ' - ', l(announcement.ends_at.to_date)])
|
||||
else
|
||||
safe_join([l(announcement.starts_at), ' - ', l(announcement.ends_at)])
|
||||
end
|
||||
end
|
||||
end
|
@ -1,15 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin::FilterHelper
|
||||
ACCOUNT_FILTERS = %i(local remote by_domain active pending silenced suspended username display_name email ip staff).freeze
|
||||
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
|
||||
INVITE_FILTER = %i(available expired).freeze
|
||||
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
|
||||
TAGS_FILTERS = %i(directory reviewed unreviewed pending_review popular active name).freeze
|
||||
INSTANCES_FILTERS = %i(limited by_domain).freeze
|
||||
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze
|
||||
|
||||
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS + FOLLOWERS_FILTERS
|
||||
FILTERS = [
|
||||
AccountFilter::KEYS,
|
||||
CustomEmojiFilter::KEYS,
|
||||
ReportFilter::KEYS,
|
||||
TagFilter::KEYS,
|
||||
InstanceFilter::KEYS,
|
||||
InviteFilter::KEYS,
|
||||
RelationshipFilter::KEYS,
|
||||
AnnouncementFilter::KEYS,
|
||||
].flatten.freeze
|
||||
|
||||
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
|
||||
new_url = filtered_url_for(link_to_params)
|
||||
|
@ -6,7 +6,7 @@ module DomainControlHelper
|
||||
|
||||
domain = begin
|
||||
if uri_or_domain.include?('://')
|
||||
Addressable::URI.parse(uri_or_domain).domain
|
||||
Addressable::URI.parse(uri_or_domain).host
|
||||
else
|
||||
uri_or_domain
|
||||
end
|
||||
|
@ -13,13 +13,13 @@ module RoutingHelper
|
||||
end
|
||||
|
||||
def full_asset_url(source, **options)
|
||||
source = ActionController::Base.helpers.asset_url(source, options) unless use_storage?
|
||||
source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage?
|
||||
|
||||
URI.join(root_url, source).to_s
|
||||
end
|
||||
|
||||
def full_pack_url(source, **options)
|
||||
full_asset_url(asset_pack_path(source, options))
|
||||
full_asset_url(asset_pack_path(source, **options))
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -32,15 +32,19 @@ module SettingsHelper
|
||||
hy: 'Հայերեն',
|
||||
id: 'Bahasa Indonesia',
|
||||
io: 'Ido',
|
||||
is: 'Íslenska',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
ka: 'ქართული',
|
||||
kab: 'Taqbaylit',
|
||||
kk: 'Қазақша',
|
||||
kn: 'ಕನ್ನಡ',
|
||||
ko: '한국어',
|
||||
lt: 'Lietuvių',
|
||||
lv: 'Latviešu',
|
||||
mk: 'Македонски',
|
||||
ml: 'മലയാളം',
|
||||
mr: 'मराठी',
|
||||
ms: 'Bahasa Melayu',
|
||||
nl: 'Nederlands',
|
||||
nn: 'Nynorsk',
|
||||
@ -63,6 +67,7 @@ module SettingsHelper
|
||||
th: 'ไทย',
|
||||
tr: 'Türkçe',
|
||||
uk: 'Українська',
|
||||
ur: 'اُردُو',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-HK': '繁體中文(香港)',
|
||||
'zh-TW': '繁體中文(臺灣)',
|
||||
|
@ -4,80 +4,6 @@ module StatusesHelper
|
||||
EMBEDDED_CONTROLLER = 'statuses'
|
||||
EMBEDDED_ACTION = 'embed'
|
||||
|
||||
def display_name(account, **options)
|
||||
if options[:custom_emojify]
|
||||
Formatter.instance.format_display_name(account, options)
|
||||
else
|
||||
account.display_name.presence || account.username
|
||||
end
|
||||
end
|
||||
|
||||
def account_action_button(account)
|
||||
if user_signed_in?
|
||||
if account.id == current_user.account_id
|
||||
link_to settings_profile_url, class: 'button logo-button' do
|
||||
safe_join([svg_logo, t('settings.edit_profile')])
|
||||
end
|
||||
elsif current_account.following?(account) || current_account.requested?(account)
|
||||
link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do
|
||||
safe_join([svg_logo, t('accounts.unfollow')])
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_follow_path(account), class: "button logo-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post } do
|
||||
safe_join([svg_logo, t('accounts.follow')])
|
||||
end
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_remote_follow_path(account), class: 'button logo-button modal-button', target: '_new' do
|
||||
safe_join([svg_logo, t('accounts.follow')])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def minimal_account_action_button(account)
|
||||
if user_signed_in?
|
||||
return if account.id == current_user.account_id
|
||||
|
||||
if current_account.following?(account) || current_account.requested?(account)
|
||||
link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
|
||||
fa_icon('user-times fw')
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
|
||||
fa_icon('user-plus fw')
|
||||
end
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
|
||||
fa_icon('user-plus fw')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def svg_logo
|
||||
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
|
||||
end
|
||||
|
||||
def svg_logo_full
|
||||
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo-full'), 'viewBox' => '0 0 713.35878 175.8678')
|
||||
end
|
||||
|
||||
def account_badge(account, all: false)
|
||||
if account.bot?
|
||||
content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
|
||||
elsif (Setting.show_staff_badge && account.user_staff?) || all
|
||||
content_tag(:div, class: 'roles') do
|
||||
if all && !account.user_staff?
|
||||
content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role')
|
||||
elsif account.user_admin?
|
||||
content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin')
|
||||
elsif account.user_moderator?
|
||||
content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def link_to_more(url)
|
||||
link_to t('statuses.show_more'), url, class: 'load-more load-gap'
|
||||
end
|
||||
@ -88,27 +14,6 @@ module StatusesHelper
|
||||
end
|
||||
end
|
||||
|
||||
def account_description(account)
|
||||
prepend_str = [
|
||||
[
|
||||
number_to_human(account.statuses_count, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.posts', count: account.statuses_count),
|
||||
].join(' '),
|
||||
|
||||
[
|
||||
number_to_human(account.following_count, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.following', count: account.following_count),
|
||||
].join(' '),
|
||||
|
||||
[
|
||||
number_to_human(account.followers_count, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.followers', count: account.followers_count),
|
||||
].join(' '),
|
||||
].join(', ')
|
||||
|
||||
[prepend_str, account.note].join(' · ')
|
||||
end
|
||||
|
||||
def media_summary(status)
|
||||
attachments = { image: 0, video: 0 }
|
||||
|
||||
@ -154,14 +59,6 @@ module StatusesHelper
|
||||
embedded_view? ? '_blank' : nil
|
||||
end
|
||||
|
||||
def acct(account)
|
||||
if account.local?
|
||||
"@#{account.acct}@#{Rails.configuration.x.local_domain}"
|
||||
else
|
||||
"@#{account.acct}"
|
||||
end
|
||||
end
|
||||
|
||||
def style_classes(status, is_predecessor, is_successor, include_threads)
|
||||
classes = ['entry']
|
||||
classes << 'entry-predecessor' if is_predecessor
|
||||
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
180
app/javascript/mastodon/actions/announcements.js
Normal file
180
app/javascript/mastodon/actions/announcements.js
Normal file
@ -0,0 +1,180 @@
|
||||
import api from '../api';
|
||||
import { normalizeAnnouncement } from './importer/normalizer';
|
||||
|
||||
export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
|
||||
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
|
||||
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
|
||||
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
|
||||
export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE';
|
||||
|
||||
export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST';
|
||||
export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS';
|
||||
export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL';
|
||||
|
||||
export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
|
||||
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
|
||||
export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
|
||||
|
||||
export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
|
||||
export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
|
||||
export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
|
||||
|
||||
export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
|
||||
|
||||
export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW';
|
||||
|
||||
const noOp = () => {};
|
||||
|
||||
export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
|
||||
dispatch(fetchAnnouncementsRequest());
|
||||
|
||||
api(getState).get('/api/v1/announcements').then(response => {
|
||||
dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x))));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAnnouncementsFail(error));
|
||||
}).finally(() => {
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchAnnouncementsRequest = () => ({
|
||||
type: ANNOUNCEMENTS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const fetchAnnouncementsSuccess = announcements => ({
|
||||
type: ANNOUNCEMENTS_FETCH_SUCCESS,
|
||||
announcements,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const fetchAnnouncementsFail= error => ({
|
||||
type: ANNOUNCEMENTS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
});
|
||||
|
||||
export const updateAnnouncements = announcement => ({
|
||||
type: ANNOUNCEMENTS_UPDATE,
|
||||
announcement: normalizeAnnouncement(announcement),
|
||||
});
|
||||
|
||||
export const dismissAnnouncement = announcementId => (dispatch, getState) => {
|
||||
dispatch(dismissAnnouncementRequest(announcementId));
|
||||
|
||||
api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => {
|
||||
dispatch(dismissAnnouncementSuccess(announcementId));
|
||||
}).catch(error => {
|
||||
dispatch(dismissAnnouncementFail(announcementId, error));
|
||||
});
|
||||
};
|
||||
|
||||
export const dismissAnnouncementRequest = announcementId => ({
|
||||
type: ANNOUNCEMENTS_DISMISS_REQUEST,
|
||||
id: announcementId,
|
||||
});
|
||||
|
||||
export const dismissAnnouncementSuccess = announcementId => ({
|
||||
type: ANNOUNCEMENTS_DISMISS_SUCCESS,
|
||||
id: announcementId,
|
||||
});
|
||||
|
||||
export const dismissAnnouncementFail = (announcementId, error) => ({
|
||||
type: ANNOUNCEMENTS_DISMISS_FAIL,
|
||||
id: announcementId,
|
||||
error,
|
||||
});
|
||||
|
||||
export const addReaction = (announcementId, name) => (dispatch, getState) => {
|
||||
const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId);
|
||||
|
||||
let alreadyAdded = false;
|
||||
|
||||
if (announcement) {
|
||||
const reaction = announcement.get('reactions').find(x => x.get('name') === name);
|
||||
if (reaction && reaction.get('me')) {
|
||||
alreadyAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyAdded) {
|
||||
dispatch(addReactionRequest(announcementId, name, alreadyAdded));
|
||||
}
|
||||
|
||||
api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
|
||||
dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
|
||||
}).catch(err => {
|
||||
if (!alreadyAdded) {
|
||||
dispatch(addReactionFail(announcementId, name, err));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const addReactionRequest = (announcementId, name) => ({
|
||||
type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
|
||||
id: announcementId,
|
||||
name,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const addReactionSuccess = (announcementId, name) => ({
|
||||
type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
|
||||
id: announcementId,
|
||||
name,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const addReactionFail = (announcementId, name, error) => ({
|
||||
type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
|
||||
id: announcementId,
|
||||
name,
|
||||
error,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const removeReaction = (announcementId, name) => (dispatch, getState) => {
|
||||
dispatch(removeReactionRequest(announcementId, name));
|
||||
|
||||
api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
|
||||
dispatch(removeReactionSuccess(announcementId, name));
|
||||
}).catch(err => {
|
||||
dispatch(removeReactionFail(announcementId, name, err));
|
||||
});
|
||||
};
|
||||
|
||||
export const removeReactionRequest = (announcementId, name) => ({
|
||||
type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
|
||||
id: announcementId,
|
||||
name,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const removeReactionSuccess = (announcementId, name) => ({
|
||||
type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
|
||||
id: announcementId,
|
||||
name,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const removeReactionFail = (announcementId, name, error) => ({
|
||||
type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
|
||||
id: announcementId,
|
||||
name,
|
||||
error,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
export const updateReaction = reaction => ({
|
||||
type: ANNOUNCEMENTS_REACTION_UPDATE,
|
||||
reaction,
|
||||
});
|
||||
|
||||
export const toggleShowAnnouncements = () => ({
|
||||
type: ANNOUNCEMENTS_TOGGLE_SHOW,
|
||||
});
|
||||
|
||||
export const deleteAnnouncement = id => ({
|
||||
type: ANNOUNCEMENTS_DELETE,
|
||||
id,
|
||||
});
|
90
app/javascript/mastodon/actions/bookmarks.js
Normal file
90
app/javascript/mastodon/actions/bookmarks.js
Normal file
@ -0,0 +1,90 @@
|
||||
import api, { getLinks } from '../api';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
|
||||
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
|
||||
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
|
||||
export const BOOKMARKED_STATUSES_FETCH_FAIL = 'BOOKMARKED_STATUSES_FETCH_FAIL';
|
||||
|
||||
export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_REQUEST';
|
||||
export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS';
|
||||
export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL';
|
||||
|
||||
export function fetchBookmarkedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchBookmarkedStatusesRequest());
|
||||
|
||||
api(getState).get('/api/v1/bookmarks').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchBookmarkedStatusesFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchBookmarkedStatusesRequest() {
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchBookmarkedStatusesSuccess(statuses, next) {
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_FETCH_SUCCESS,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchBookmarkedStatusesFail(error) {
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBookmarkedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['status_lists', 'bookmarks', 'next'], null);
|
||||
|
||||
if (url === null || getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandBookmarkedStatusesRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandBookmarkedStatusesFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBookmarkedStatusesRequest() {
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBookmarkedStatusesSuccess(statuses, next) {
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBookmarkedStatusesFail(error) {
|
||||
return {
|
||||
type: BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
@ -207,10 +207,11 @@ export function uploadCompose(files) {
|
||||
return function (dispatch, getState) {
|
||||
const uploadLimit = 4;
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const pending = getState().getIn(['compose', 'pending_media_attachments']);
|
||||
const progress = new Array(files.length).fill(0);
|
||||
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
|
||||
|
||||
if (files.length + media.size > uploadLimit) {
|
||||
if (files.length + media.size + pending > uploadLimit) {
|
||||
dispatch(showAlert(undefined, messages.uploadErrorLimit));
|
||||
return;
|
||||
}
|
||||
|
@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
export function searchTextFromRawStatus (status) {
|
||||
const spoilerText = status.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||
}
|
||||
|
||||
export function normalizeAccount(account) {
|
||||
account = { ...account };
|
||||
|
||||
@ -70,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||
|
||||
export function normalizePoll(poll) {
|
||||
const normalPoll = { ...poll };
|
||||
|
||||
const emojiMap = makeEmojiMap(normalPoll);
|
||||
|
||||
normalPoll.options = poll.options.map((option, index) => ({
|
||||
@ -81,3 +86,12 @@ export function normalizePoll(poll) {
|
||||
|
||||
return normalPoll;
|
||||
}
|
||||
|
||||
export function normalizeAnnouncement(announcement) {
|
||||
const normalAnnouncement = { ...announcement };
|
||||
const emojiMap = makeEmojiMap(normalAnnouncement);
|
||||
|
||||
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
||||
|
||||
return normalAnnouncement;
|
||||
}
|
||||
|
@ -33,6 +33,14 @@ export const UNPIN_REQUEST = 'UNPIN_REQUEST';
|
||||
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
|
||||
export const UNPIN_FAIL = 'UNPIN_FAIL';
|
||||
|
||||
export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST';
|
||||
export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS';
|
||||
export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL';
|
||||
|
||||
export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
|
||||
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
|
||||
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
|
||||
|
||||
export function reblog(status) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(reblogRequest(status));
|
||||
@ -187,6 +195,78 @@ export function unfavouriteFail(status, error) {
|
||||
};
|
||||
};
|
||||
|
||||
export function bookmark(status) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(bookmarkRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) {
|
||||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(bookmarkSuccess(status, response.data));
|
||||
}).catch(function (error) {
|
||||
dispatch(bookmarkFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unbookmark(status) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unbookmarkRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
|
||||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(unbookmarkSuccess(status, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(unbookmarkFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function bookmarkRequest(status) {
|
||||
return {
|
||||
type: BOOKMARK_REQUEST,
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
|
||||
export function bookmarkSuccess(status, response) {
|
||||
return {
|
||||
type: BOOKMARK_SUCCESS,
|
||||
status: status,
|
||||
response: response,
|
||||
};
|
||||
};
|
||||
|
||||
export function bookmarkFail(status, error) {
|
||||
return {
|
||||
type: BOOKMARK_FAIL,
|
||||
status: status,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unbookmarkRequest(status) {
|
||||
return {
|
||||
type: UNBOOKMARK_REQUEST,
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
|
||||
export function unbookmarkSuccess(status, response) {
|
||||
return {
|
||||
type: UNBOOKMARK_SUCCESS,
|
||||
status: status,
|
||||
response: response,
|
||||
};
|
||||
};
|
||||
|
||||
export function unbookmarkFail(status, error) {
|
||||
return {
|
||||
type: UNBOOKMARK_FAIL,
|
||||
status: status,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogs(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchReblogsRequest(id));
|
||||
|
@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html';
|
||||
import { getFiltersRegex } from '../selectors';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
|
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
|
||||
@ -60,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
if (notification.type === 'mention') {
|
||||
const dropRegex = filters[0];
|
||||
const regex = filters[1];
|
||||
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
|
||||
const searchIndex = searchTextFromRawStatus(notification.status);
|
||||
|
||||
if (dropRegex && dropRegex.test(searchIndex)) {
|
||||
return;
|
||||
@ -109,7 +110,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
||||
|
||||
const excludeTypesFromFilter = filter => {
|
||||
const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
|
||||
const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
|
||||
return allTypes.filterNot(item => item === filter).toJS();
|
||||
};
|
||||
|
||||
@ -156,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
done();
|
||||
});
|
||||
};
|
||||
@ -187,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
|
||||
type: NOTIFICATIONS_EXPAND_FAIL,
|
||||
error,
|
||||
skipLoading: !isLoadingMore,
|
||||
skipAlert: !isLoadingMore,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -26,8 +26,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
|
||||
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
|
||||
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
|
||||
|
||||
export const STATUS_REVEAL = 'STATUS_REVEAL';
|
||||
export const STATUS_HIDE = 'STATUS_HIDE';
|
||||
export const STATUS_REVEAL = 'STATUS_REVEAL';
|
||||
export const STATUS_HIDE = 'STATUS_HIDE';
|
||||
export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
|
||||
|
||||
export const REDRAFT = 'REDRAFT';
|
||||
|
||||
@ -320,3 +321,11 @@ export function revealStatus(ids) {
|
||||
ids,
|
||||
};
|
||||
};
|
||||
|
||||
export function toggleStatusCollapse(id, isCollapsed) {
|
||||
return {
|
||||
type: STATUS_COLLAPSE,
|
||||
id,
|
||||
isCollapsed,
|
||||
};
|
||||
}
|
||||
|
@ -8,6 +8,12 @@ import {
|
||||
} from './timelines';
|
||||
import { updateNotifications, expandNotifications } from './notifications';
|
||||
import { updateConversations } from './conversations';
|
||||
import {
|
||||
fetchAnnouncements,
|
||||
updateAnnouncements,
|
||||
updateReaction as updateAnnouncementsReaction,
|
||||
deleteAnnouncement,
|
||||
} from './announcements';
|
||||
import { fetchFilters } from './filters';
|
||||
import { getLocale } from '../locales';
|
||||
|
||||
@ -44,6 +50,15 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
|
||||
case 'filters_changed':
|
||||
dispatch(fetchFilters());
|
||||
break;
|
||||
case 'announcement':
|
||||
dispatch(updateAnnouncements(JSON.parse(data.payload)));
|
||||
break;
|
||||
case 'announcement.reaction':
|
||||
dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
|
||||
break;
|
||||
case 'announcement.delete':
|
||||
dispatch(deleteAnnouncement(data.payload));
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -51,7 +66,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
|
||||
}
|
||||
|
||||
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
||||
dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
|
||||
dispatch(expandHomeTimeline({}, () =>
|
||||
dispatch(expandNotifications({}, () =>
|
||||
dispatch(fetchAnnouncements(done))))));
|
||||
};
|
||||
|
||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||
|
@ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
done();
|
||||
});
|
||||
};
|
||||
|
65
app/javascript/mastodon/components/animated_number.js
Normal file
65
app/javascript/mastodon/components/animated_number.js
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import { reduceMotion } from 'mastodon/initial_state';
|
||||
|
||||
export default class AnimatedNumber extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
direction: 1,
|
||||
};
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.value > this.props.value) {
|
||||
this.setState({ direction: 1 });
|
||||
} else if (nextProps.value < this.props.value) {
|
||||
this.setState({ direction: -1 });
|
||||
}
|
||||
}
|
||||
|
||||
willEnter = () => {
|
||||
const { direction } = this.state;
|
||||
|
||||
return { y: -1 * direction };
|
||||
}
|
||||
|
||||
willLeave = () => {
|
||||
const { direction } = this.state;
|
||||
|
||||
return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value } = this.props;
|
||||
const { direction } = this.state;
|
||||
|
||||
if (reduceMotion) {
|
||||
return <FormattedNumber value={value} />;
|
||||
}
|
||||
|
||||
const styles = [{
|
||||
key: `${value}`,
|
||||
data: value,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
}];
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
|
||||
{items => (
|
||||
<span className='animated-number'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -25,7 +25,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={displayUrl} target='_blank' rel='noopener'><Icon id='link' /> {filename(displayUrl)}</a>
|
||||
<a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@ -46,7 +46,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={displayUrl} target='_blank' rel='noopener'>{filename(displayUrl)}</a>
|
||||
<a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
@ -33,6 +33,7 @@ class ColumnHeader extends React.PureComponent {
|
||||
onPin: PropTypes.func,
|
||||
onMove: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
appendContent: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -81,7 +82,7 @@ class ColumnHeader extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder } = this.props;
|
||||
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
|
||||
const { collapsed, animating } = this.state;
|
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', {
|
||||
@ -172,6 +173,8 @@ class ColumnHeader extends React.PureComponent {
|
||||
{(!collapsed || animating) && collapsedContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appendContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -143,7 +143,7 @@ class DropdownMenu extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<li className='dropdown-menu__item' key={`${text}-${i}`}>
|
||||
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
|
@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {
|
||||
<div>
|
||||
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
|
||||
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,4 @@
|
||||
import React from 'react';
|
||||
import Motion from '../features/ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
@ -37,6 +35,21 @@ export default class IconButton extends React.PureComponent {
|
||||
tabIndex: '0',
|
||||
};
|
||||
|
||||
state = {
|
||||
activate: false,
|
||||
deactivate: false,
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (!nextProps.animate) return;
|
||||
|
||||
if (this.props.active && !nextProps.active) {
|
||||
this.setState({ activate: false, deactivate: true });
|
||||
} else if (!this.props.active && nextProps.active) {
|
||||
this.setState({ activate: true, deactivate: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -75,7 +88,6 @@ export default class IconButton extends React.PureComponent {
|
||||
|
||||
const {
|
||||
active,
|
||||
animate,
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
@ -87,57 +99,37 @@ export default class IconButton extends React.PureComponent {
|
||||
title,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
activate,
|
||||
deactivate,
|
||||
} = this.state;
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
disabled,
|
||||
inverted,
|
||||
activate,
|
||||
deactivate,
|
||||
overlayed: overlay,
|
||||
});
|
||||
|
||||
if (!animate) {
|
||||
// Perf optimization: avoid unnecessary <Motion> components unless
|
||||
// we actually need to animate.
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||
{({ rotate }) => (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon id={icon} style={{ transform: `rotate(${rotate}deg)` }} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
)}
|
||||
</Motion>
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from '../is_mobile';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||
import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -23,6 +23,7 @@ class Item extends React.PureComponent {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
displayWidth: PropTypes.number,
|
||||
visible: PropTypes.bool.isRequired,
|
||||
autoplay: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -48,9 +49,13 @@ class Item extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
getAutoPlay() {
|
||||
return this.props.autoplay || autoPlayGif;
|
||||
}
|
||||
|
||||
hoverToPlay () {
|
||||
const { attachment } = this.props;
|
||||
return !autoPlayGif && attachment.get('type') === 'gifv';
|
||||
return !this.getAutoPlay() && attachment.get('type') === 'gifv';
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
@ -159,7 +164,7 @@ class Item extends React.PureComponent {
|
||||
if (attachment.get('type') === 'unknown') {
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} target='_blank' style={{ cursor: 'pointer' }} title={attachment.get('description')}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} target='_blank' rel='noopener noreferrer'>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
||||
</a>
|
||||
</div>
|
||||
@ -187,6 +192,7 @@ class Item extends React.PureComponent {
|
||||
href={attachment.get('remote_url') || originalUrl}
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src={previewUrl}
|
||||
@ -200,7 +206,7 @@ class Item extends React.PureComponent {
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
const autoPlay = !isIOS() && autoPlayGif;
|
||||
const autoPlay = !isIOS() && this.getAutoPlay();
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
@ -247,6 +253,7 @@ class MediaGallery extends React.PureComponent {
|
||||
defaultWidth: PropTypes.number,
|
||||
cacheWidth: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
autoplay: PropTypes.bool,
|
||||
onToggleVisibility: PropTypes.func,
|
||||
};
|
||||
|
||||
@ -280,7 +287,7 @@ class MediaGallery extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
if (node /*&& this.isStandaloneEligible()*/) {
|
||||
if (node) {
|
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
||||
|
||||
@ -290,13 +297,13 @@ class MediaGallery extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
isStandaloneEligible() {
|
||||
const { media, standalone } = this.props;
|
||||
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
isFullSizeEligible() {
|
||||
const { media } = this.props;
|
||||
return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, intl, sensitive, height, defaultWidth } = this.props;
|
||||
const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
|
||||
const { visible } = this.state;
|
||||
|
||||
const width = this.state.width || defaultWidth;
|
||||
@ -305,7 +312,7 @@ class MediaGallery extends React.PureComponent {
|
||||
|
||||
const style = {};
|
||||
|
||||
if (this.isStandaloneEligible()) {
|
||||
if (this.isFullSizeEligible() && (standalone || !cropImages)) {
|
||||
if (width) {
|
||||
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
}
|
||||
@ -318,10 +325,10 @@ class MediaGallery extends React.PureComponent {
|
||||
const size = media.take(4).size;
|
||||
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||
if (standalone && this.isFullSizeEligible()) {
|
||||
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||
}
|
||||
|
||||
if (uncached) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import 'wicg-inert';
|
||||
|
||||
export default class ModalRoot extends React.PureComponent {
|
||||
|
||||
@ -55,15 +56,21 @@ export default class ModalRoot extends React.PureComponent {
|
||||
} else if (!nextProps.children) {
|
||||
this.setState({ revealed: false });
|
||||
}
|
||||
if (!nextProps.children && !!this.props.children) {
|
||||
this.activeElement.focus();
|
||||
this.activeElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (!this.props.children && !!prevProps.children) {
|
||||
this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
|
||||
|
||||
// Because of the wicg-inert polyfill, the activeElement may not be
|
||||
// immediately selectable, we have to wait for observers to run, as
|
||||
// described in https://github.com/WICG/inert#performance-and-gotchas
|
||||
Promise.resolve().then(() => {
|
||||
this.activeElement.focus();
|
||||
this.activeElement = null;
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
if (this.props.children) {
|
||||
requestAnimationFrame(() => {
|
||||
|
@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent {
|
||||
|
||||
static getDerivedStateFromProps (props, state) {
|
||||
const { poll, intl } = props;
|
||||
const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now();
|
||||
const expires_at = poll.get('expires_at');
|
||||
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
|
||||
return (expired === state.expired) ? null : { expired };
|
||||
}
|
||||
|
||||
@ -66,9 +67,7 @@ class Poll extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleOptionChange = e => {
|
||||
const { target: { value } } = e;
|
||||
|
||||
_toggleOption = value => {
|
||||
if (this.props.poll.get('multiple')) {
|
||||
const tmp = { ...this.state.selected };
|
||||
if (tmp[value]) {
|
||||
@ -82,8 +81,20 @@ class Poll extends ImmutablePureComponent {
|
||||
tmp[value] = true;
|
||||
this.setState({ selected: tmp });
|
||||
}
|
||||
}
|
||||
|
||||
handleOptionChange = ({ target: { value } }) => {
|
||||
this._toggleOption(value);
|
||||
};
|
||||
|
||||
handleOptionKeyPress = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this._toggleOption(e.target.getAttribute('data-index'));
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
handleVote = () => {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
@ -134,7 +145,17 @@ class Poll extends ImmutablePureComponent {
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{!showResults && <span className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} />}
|
||||
{!showResults && (
|
||||
<span
|
||||
className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
|
||||
tabIndex='0'
|
||||
role={poll.get('multiple') ? 'checkbox' : 'radio'}
|
||||
onKeyPress={this.handleOptionKeyPress}
|
||||
aria-checked={active}
|
||||
aria-label={option.get('title')}
|
||||
data-index={optionIndex}
|
||||
/>
|
||||
)}
|
||||
{showResults && <span className='poll__number'>
|
||||
{!!voted && <Icon id='check' className='poll__vote__mark' title={intl.formatMessage(messages.voted)} />}
|
||||
{Math.round(percent)}%
|
||||
|
@ -3,6 +3,7 @@ import { injectIntl, defineMessages } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const messages = defineMessages({
|
||||
today: { id: 'relative_time.today', defaultMessage: 'today' },
|
||||
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
|
||||
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
|
||||
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
|
||||
@ -65,12 +66,14 @@ const getUnitDelay = units => {
|
||||
}
|
||||
};
|
||||
|
||||
export const timeAgoString = (intl, date, now, year) => {
|
||||
export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
|
||||
const delta = now - date.getTime();
|
||||
|
||||
let relativeTime;
|
||||
|
||||
if (delta < 10 * SECOND) {
|
||||
if (delta < DAY && !timeGiven) {
|
||||
relativeTime = intl.formatMessage(messages.today);
|
||||
} else if (delta < 10 * SECOND) {
|
||||
relativeTime = intl.formatMessage(messages.just_now);
|
||||
} else if (delta < 7 * DAY) {
|
||||
if (delta < MINUTE) {
|
||||
@ -91,12 +94,14 @@ export const timeAgoString = (intl, date, now, year) => {
|
||||
return relativeTime;
|
||||
};
|
||||
|
||||
const timeRemainingString = (intl, date, now) => {
|
||||
const timeRemainingString = (intl, date, now, timeGiven = true) => {
|
||||
const delta = date.getTime() - now;
|
||||
|
||||
let relativeTime;
|
||||
|
||||
if (delta < 10 * SECOND) {
|
||||
if (delta < DAY && !timeGiven) {
|
||||
relativeTime = intl.formatMessage(messages.today);
|
||||
} else if (delta < 10 * SECOND) {
|
||||
relativeTime = intl.formatMessage(messages.moments_remaining);
|
||||
} else if (delta < MINUTE) {
|
||||
relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
|
||||
@ -173,8 +178,9 @@ class RelativeTimestamp extends React.Component {
|
||||
render () {
|
||||
const { timestamp, intl, year, futureDate } = this.props;
|
||||
|
||||
const timeGiven = timestamp.includes('T');
|
||||
const date = new Date(timestamp);
|
||||
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
|
||||
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven);
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
||||
|
@ -208,10 +208,13 @@ export default class ScrollableList extends PureComponent {
|
||||
}
|
||||
|
||||
attachIntersectionObserver () {
|
||||
this.intersectionObserverWrapper.connect({
|
||||
let nodeOptions = {
|
||||
root: this.node,
|
||||
rootMargin: '300% 0px',
|
||||
});
|
||||
};
|
||||
|
||||
this.intersectionObserverWrapper
|
||||
.connect(this.props.bindToDocument ? {} : nodeOptions);
|
||||
}
|
||||
|
||||
detachIntersectionObserver () {
|
||||
@ -293,7 +296,7 @@ export default class ScrollableList extends PureComponent {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
|
||||
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
<div role='feed' className='item-list'>
|
||||
|
@ -76,6 +76,7 @@ class Status extends ImmutablePureComponent {
|
||||
onEmbed: PropTypes.func,
|
||||
onHeightChange: PropTypes.func,
|
||||
onToggleHidden: PropTypes.func,
|
||||
onToggleCollapsed: PropTypes.func,
|
||||
muted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
unread: PropTypes.bool,
|
||||
@ -102,19 +103,6 @@ class Status extends ImmutablePureComponent {
|
||||
statusId: undefined,
|
||||
};
|
||||
|
||||
// Track height changes we know about to compensate scrolling
|
||||
componentDidMount () {
|
||||
this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
|
||||
}
|
||||
|
||||
getSnapshotBeforeUpdate () {
|
||||
if (this.props.getScrollPosition) {
|
||||
return this.props.getScrollPosition();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
||||
return {
|
||||
@ -126,32 +114,6 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// Compensate height changes
|
||||
componentDidUpdate (prevProps, prevState, snapshot) {
|
||||
const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card');
|
||||
|
||||
if (doShowCard && !this.didShowCard) {
|
||||
this.didShowCard = true;
|
||||
|
||||
if (snapshot !== null && this.props.updateScrollBottom) {
|
||||
if (this.node && this.node.offsetTop < snapshot.top) {
|
||||
this.props.updateScrollBottom(snapshot.height - snapshot.top);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.node && this.props.getScrollPosition) {
|
||||
const position = this.props.getScrollPosition();
|
||||
if (position !== null && this.node.offsetTop < position.top) {
|
||||
requestAnimationFrame(() => {
|
||||
this.props.updateScrollBottom(position.height - position.top);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
@ -196,7 +158,11 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
handleExpandedToggle = () => {
|
||||
this.props.onToggleHidden(this._properStatus());
|
||||
};
|
||||
}
|
||||
|
||||
handleCollapsedToggle = isCollapsed => {
|
||||
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
|
||||
}
|
||||
|
||||
renderLoadingMediaGallery () {
|
||||
return <div className='media-gallery' style={{ height: '110px' }} />;
|
||||
@ -214,6 +180,23 @@ class Status extends ImmutablePureComponent {
|
||||
this.props.onOpenVideo(media, startTime);
|
||||
}
|
||||
|
||||
handleHotkeyOpenMedia = e => {
|
||||
const { onOpenMedia, onOpenVideo } = this.props;
|
||||
const status = this._properStatus();
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
// TODO: toggle play/paused?
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
onOpenVideo(status.getIn(['media_attachments', 0]), 0);
|
||||
} else {
|
||||
onOpenMedia(status.get('media_attachments'), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
this.props.onReply(this._properStatus(), this.context.router.history);
|
||||
@ -293,6 +276,7 @@ class Status extends ImmutablePureComponent {
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
openMedia: this.handleHotkeyOpenMedia,
|
||||
};
|
||||
|
||||
if (hidden) {
|
||||
@ -437,9 +421,9 @@ class Status extends ImmutablePureComponent {
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
|
||||
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
|
||||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
|
||||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
|
||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
@ -448,7 +432,7 @@ class Status extends ImmutablePureComponent {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} collapsable />
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
|
||||
|
||||
{media}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from './icon_button';
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
@ -24,6 +25,8 @@ const messages = defineMessages({
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
@ -34,6 +37,10 @@ const messages = defineMessages({
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
|
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
});
|
||||
|
||||
const obfuscatedCount = count => {
|
||||
@ -46,7 +53,12 @@ const obfuscatedCount = count => {
|
||||
}
|
||||
};
|
||||
|
||||
export default @injectIntl
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
@ -55,6 +67,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
relationship: ImmutablePropTypes.map,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
@ -62,11 +75,16 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onUnmute: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onUnblock: PropTypes.func,
|
||||
onBlockDomain: PropTypes.func,
|
||||
onUnblockDomain: PropTypes.func,
|
||||
onReport: PropTypes.func,
|
||||
onEmbed: PropTypes.func,
|
||||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
@ -75,6 +93,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'relationship',
|
||||
'withDismiss',
|
||||
]
|
||||
|
||||
@ -115,6 +134,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
}
|
||||
|
||||
handleBookmarkClick = () => {
|
||||
this.props.onBookmark(this.props.status);
|
||||
}
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status, this.context.router.history);
|
||||
}
|
||||
@ -136,11 +159,39 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
handleMuteClick = () => {
|
||||
this.props.onMute(this.props.status.get('account'));
|
||||
const { status, relationship, onMute, onUnmute } = this.props;
|
||||
const account = status.get('account');
|
||||
|
||||
if (relationship && relationship.get('muting')) {
|
||||
onUnmute(account);
|
||||
} else {
|
||||
onMute(account);
|
||||
}
|
||||
}
|
||||
|
||||
handleBlockClick = () => {
|
||||
this.props.onBlock(this.props.status);
|
||||
const { status, relationship, onBlock, onUnblock } = this.props;
|
||||
const account = status.get('account');
|
||||
|
||||
if (relationship && relationship.get('blocking')) {
|
||||
onUnblock(account);
|
||||
} else {
|
||||
onBlock(status);
|
||||
}
|
||||
}
|
||||
|
||||
handleBlockDomain = () => {
|
||||
const { status, onBlockDomain } = this.props;
|
||||
const account = status.get('account');
|
||||
|
||||
onBlockDomain(account.get('acct').split('@')[1]);
|
||||
}
|
||||
|
||||
handleUnblockDomain = () => {
|
||||
const { status, onUnblockDomain } = this.props;
|
||||
const account = status.get('account');
|
||||
|
||||
onUnblockDomain(account.get('acct').split('@')[1]);
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
@ -179,11 +230,12 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, intl, withDismiss } = this.props;
|
||||
const { status, relationship, intl, withDismiss } = this.props;
|
||||
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
const account = status.get('account');
|
||||
const federated = !status.get('local_only');
|
||||
|
||||
let menu = [];
|
||||
@ -198,6 +250,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
|
||||
menu.push(null);
|
||||
|
||||
if (status.getIn(['account', 'id']) === me || withDismiss) {
|
||||
@ -217,16 +270,39 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
||||
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.handleMentionClick });
|
||||
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
|
||||
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
|
||||
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
|
||||
|
||||
if (relationship && relationship.get('muting')) {
|
||||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.handleMuteClick });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.handleMuteClick });
|
||||
}
|
||||
|
||||
if (relationship && relationship.get('blocking')) {
|
||||
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.handleBlockClick });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.handleBlockClick });
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.handleReport });
|
||||
|
||||
if (account.get('acct') !== account.get('username')) {
|
||||
const domain = account.get('acct').split('@')[1];
|
||||
|
||||
menu.push(null);
|
||||
|
||||
if (relationship && relationship.get('domain_blocking')) {
|
||||
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.handleUnblockDomain });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
|
||||
}
|
||||
}
|
||||
|
||||
if (isStaff) {
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
|
||||
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
|
||||
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
|
||||
}
|
||||
}
|
||||
@ -245,7 +321,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
|
||||
const shareButton = ('share' in navigator) && publicStatus && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
|
||||
|
@ -23,11 +23,11 @@ export default class StatusContent extends React.PureComponent {
|
||||
onExpandedToggle: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
collapsable: PropTypes.bool,
|
||||
onCollapsedToggle: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
hidden: true,
|
||||
collapsed: null, // `collapsed: null` indicates that an element doesn't need collapsing, while `true` or `false` indicates that it does (and is/isn't).
|
||||
};
|
||||
|
||||
_updateStatusLinks () {
|
||||
@ -59,17 +59,19 @@ export default class StatusContent extends React.PureComponent {
|
||||
}
|
||||
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
|
||||
if (
|
||||
this.props.collapsable
|
||||
&& this.props.onClick
|
||||
&& this.state.collapsed === null
|
||||
&& node.clientHeight > MAX_HEIGHT
|
||||
&& this.props.status.get('spoiler_text').length === 0
|
||||
) {
|
||||
this.setState({ collapsed: true });
|
||||
if (this.props.status.get('collapsed', null) === null) {
|
||||
let collapsed =
|
||||
this.props.collapsable
|
||||
&& this.props.onClick
|
||||
&& node.clientHeight > MAX_HEIGHT
|
||||
&& this.props.status.get('spoiler_text').length === 0;
|
||||
|
||||
if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed);
|
||||
|
||||
this.props.status.set('collapsed', collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
@ -178,6 +180,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
}
|
||||
|
||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
|
||||
const content = { __html: status.get('contentHtml') };
|
||||
const spoilerContent = { __html: status.get('spoilerHtml') };
|
||||
@ -185,7 +188,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': this.props.onClick && this.context.router,
|
||||
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
|
||||
'status__content--collapsed': this.state.collapsed === true,
|
||||
'status__content--collapsed': renderReadMore,
|
||||
});
|
||||
|
||||
if (isRtl(status.get('search_index'))) {
|
||||
@ -237,7 +240,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
</div>,
|
||||
];
|
||||
|
||||
if (this.state.collapsed) {
|
||||
if (renderReadMore) {
|
||||
output.push(readMoreButton);
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
import { openModal, closeModal } from '../actions/modal';
|
||||
import { connect } from 'react-redux';
|
||||
import DropdownMenu from '../components/dropdown_menu';
|
||||
@ -13,12 +14,17 @@ const mapStateToProps = state => ({
|
||||
|
||||
const mapDispatchToProps = (dispatch, { status, items }) => ({
|
||||
onOpen(id, onItemClick, dropdownPlacement, keyboard) {
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
}
|
||||
|
||||
dispatch(isUserTouching() ? openModal('ACTIONS', {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: onItemClick,
|
||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||
},
|
||||
|
||||
onClose(id) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
dispatch(closeDropdownMenu(id));
|
||||
|
@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Status from '../components/status';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
@ -9,8 +10,10 @@ import {
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
bookmark,
|
||||
unreblog,
|
||||
unfavourite,
|
||||
unbookmark,
|
||||
pin,
|
||||
unpin,
|
||||
} from '../actions/interactions';
|
||||
@ -20,12 +23,21 @@ import {
|
||||
deleteStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
toggleStatusCollapse,
|
||||
} from '../actions/statuses';
|
||||
import {
|
||||
unmuteAccount,
|
||||
unblockAccount,
|
||||
} from '../actions/accounts';
|
||||
import {
|
||||
blockDomain,
|
||||
unblockDomain,
|
||||
} from '../actions/domain_blocks';
|
||||
import { initMuteModal } from '../actions/mutes';
|
||||
import { initBlockModal } from '../actions/blocks';
|
||||
import { initReport } from '../actions/reports';
|
||||
import { openModal } from '../actions/modal';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { boostModal, deleteModal } from '../initial_state';
|
||||
import { showAlertForError } from '../actions/alerts';
|
||||
|
||||
@ -36,6 +48,7 @@ const messages = defineMessages({
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
@ -90,6 +103,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onBookmark (status) {
|
||||
if (status.get('bookmarked')) {
|
||||
dispatch(unbookmark(status));
|
||||
} else {
|
||||
dispatch(bookmark(status));
|
||||
}
|
||||
},
|
||||
|
||||
onPin (status) {
|
||||
if (status.get('pinned')) {
|
||||
dispatch(unpin(status));
|
||||
@ -138,6 +159,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
dispatch(initBlockModal(account));
|
||||
},
|
||||
|
||||
onUnblock (account) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
},
|
||||
|
||||
onReport (status) {
|
||||
dispatch(initReport(status.get('account'), status));
|
||||
},
|
||||
@ -146,6 +171,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
|
||||
onUnmute (account) {
|
||||
dispatch(unmuteAccount(account.get('id')));
|
||||
},
|
||||
|
||||
onMuteConversation (status) {
|
||||
if (status.get('muted')) {
|
||||
dispatch(unmuteStatus(status.get('id')));
|
||||
@ -162,6 +191,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onToggleCollapsed (status, isCollapsed) {
|
||||
dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
|
||||
},
|
||||
|
||||
onBlockDomain (domain) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockDomainConfirm),
|
||||
onConfirm: () => dispatch(blockDomain(domain)),
|
||||
}));
|
||||
},
|
||||
|
||||
onUnblockDomain (domain) {
|
||||
dispatch(unblockDomain(domain));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
|
||||
|
@ -1,5 +1,5 @@
|
||||
import 'intersection-observer';
|
||||
import 'requestidlecallback';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
|
||||
objectFitImages();
|
||||
|
@ -238,9 +238,18 @@ class Header extends ImmutablePureComponent {
|
||||
const content = { __html: account.get('note_emojified') };
|
||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||
const fields = account.get('fields');
|
||||
const badge = account.get('bot') ? (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>) : null;
|
||||
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
|
||||
|
||||
let badge;
|
||||
|
||||
if (account.get('bot')) {
|
||||
badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>);
|
||||
} else if (account.get('group')) {
|
||||
badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>);
|
||||
} else {
|
||||
badge = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
|
||||
<div className='account__header__image'>
|
||||
@ -253,7 +262,7 @@ class Header extends ImmutablePureComponent {
|
||||
|
||||
<div className='account__header__bar'>
|
||||
<div className='account__header__tabs'>
|
||||
<a className='avatar' href={account.get('url')} rel='noopener' target='_blank'>
|
||||
<a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'>
|
||||
<Avatar account={account} size={90} />
|
||||
</a>
|
||||
|
||||
@ -282,10 +291,10 @@ class Header extends ImmutablePureComponent {
|
||||
<dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
|
||||
|
||||
<dd className='verified'>
|
||||
<a href={proof.get('proof_url')} target='_blank' rel='noopener'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
|
||||
<a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
|
||||
<Icon id='check' className='verified__mark' />
|
||||
</span></a>
|
||||
<a href={proof.get('profile_url')} target='_blank' rel='noopener'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
|
||||
<a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { decode } from 'blurhash';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
|
||||
import classNames from 'classnames';
|
||||
import { decode } from 'blurhash';
|
||||
import { isIOS } from 'mastodon/is_mobile';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
@ -151,7 +151,7 @@ export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item' style={{ width, height }}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}>
|
||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
|
||||
{visible && thumbnail}
|
||||
{!visible && icon}
|
||||
|
@ -115,6 +115,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='account'
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ const messages = defineMessages({
|
||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
@ -36,15 +37,14 @@ class Audio extends React.PureComponent {
|
||||
volume: 0.5,
|
||||
};
|
||||
|
||||
// hard coded in components.scss
|
||||
// any way to get ::before values programatically?
|
||||
|
||||
volWidth = 50;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -60,6 +60,8 @@ class Audio extends React.PureComponent {
|
||||
if (this.waveform) {
|
||||
this._updateWaveform();
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
@ -69,6 +71,8 @@ class Audio extends React.PureComponent {
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
|
||||
if (this.wavesurfer) {
|
||||
this.wavesurfer.destroy();
|
||||
this.wavesurfer = null;
|
||||
@ -127,16 +131,15 @@ class Audio extends React.PureComponent {
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
this.wavesurfer.play();
|
||||
this.setState({ paused: false });
|
||||
this.setState({ paused: false }, () => this.wavesurfer.play());
|
||||
} else {
|
||||
this.wavesurfer.pause();
|
||||
this.setState({ paused: true });
|
||||
this.setState({ paused: true }, () => this.wavesurfer.pause());
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute = () => {
|
||||
this.wavesurfer.setMute(!this.state.muted);
|
||||
const muted = !this.state.muted;
|
||||
this.setState({ muted }, () => this.wavesurfer.setMute(muted));
|
||||
}
|
||||
|
||||
handleVolumeMouseDown = e => {
|
||||
@ -175,6 +178,19 @@ class Audio extends React.PureComponent {
|
||||
}
|
||||
}, 60);
|
||||
|
||||
handleScroll = throttle(() => {
|
||||
if (!this.waveform || !this.wavesurfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, height } = this.waveform.getBoundingClientRect();
|
||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
||||
|
||||
if (!this.state.paused && !inView) {
|
||||
this.setState({ paused: true }, () => this.wavesurfer.pause());
|
||||
}
|
||||
}, 150, { trailing: true })
|
||||
|
||||
render () {
|
||||
const { height, intl, alt, editable } = this.props;
|
||||
const { paused, muted, volume, currentTime } = this.state;
|
||||
@ -202,6 +218,7 @@ class Audio extends React.PureComponent {
|
||||
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
||||
|
||||
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||
|
||||
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
||||
|
||||
<span
|
||||
@ -217,6 +234,14 @@ class Audio extends React.PureComponent {
|
||||
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
<button type='button' aria-label={intl.formatMessage(messages.download)}>
|
||||
<a className='video-player__download__icon' href={this.props.src} download>
|
||||
<Icon id={'download'} fixedWidth />
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
104
app/javascript/mastodon/features/bookmarked_statuses/index.js
Normal file
104
app/javascript/mastodon/features/bookmarked_statuses/index.js
Normal file
@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import StatusList from '../../components/status_list';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
|
||||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Bookmarks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchBookmarkedStatuses());
|
||||
}
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('BOOKMARKS', {}));
|
||||
}
|
||||
}
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandBookmarkedStatuses());
|
||||
}, 300, { leading: true })
|
||||
|
||||
render () {
|
||||
const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
||||
<ColumnHeader
|
||||
icon='bookmark'
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<StatusList
|
||||
trackScroll={!pinned}
|
||||
statusIds={statusIds}
|
||||
scrollKey={`bookmarked_statuses-${columnId}`}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -14,15 +14,16 @@ const messages = defineMessages({
|
||||
title: { id: 'column.community', defaultMessage: 'Local timeline' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { onlyMedia, columnId }) => {
|
||||
const mapStateToProps = (state, { columnId }) => {
|
||||
const uuid = columnId;
|
||||
const columns = state.getIn(['settings', 'columns']);
|
||||
const index = columns.findIndex(c => c.get('uuid') === uuid);
|
||||
const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']);
|
||||
const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
|
||||
|
||||
return {
|
||||
hasUnread: !!timelineState && (timelineState.get('unread') > 0 || timelineState.get('pendingItems').size > 0),
|
||||
onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']),
|
||||
hasUnread: !!timelineState && timelineState.get('unread') > 0,
|
||||
onlyMedia,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
button: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -350,18 +351,18 @@ class EmojiPickerDropdown extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active, loading, placement } = this.state;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||
<img
|
||||
{button || <img
|
||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||
alt='🙂'
|
||||
src={`${assetHost}/emoji/1f602.svg`}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement={placement} target={this.findTarget}>
|
||||
|
@ -13,6 +13,8 @@ const messages = defineMessages({
|
||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
|
||||
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
|
||||
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
|
||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
|
||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
@ -50,6 +52,12 @@ class Option extends React.PureComponent {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
handleCheckboxKeypress = e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
this.handleToggleMultiple(e);
|
||||
}
|
||||
}
|
||||
|
||||
onSuggestionsClearRequested = () => {
|
||||
this.props.onClearSuggestions();
|
||||
}
|
||||
@ -71,8 +79,11 @@ class Option extends React.PureComponent {
|
||||
<span
|
||||
className={classNames('poll__input', { checkbox: isPollMultiple })}
|
||||
onClick={this.handleToggleMultiple}
|
||||
onKeyPress={this.handleCheckboxKeypress}
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
title={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
|
||||
aria-label={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
|
||||
/>
|
||||
|
||||
<AutosuggestInput
|
||||
@ -142,9 +153,7 @@ class PollForm extends ImmutablePureComponent {
|
||||
</ul>
|
||||
|
||||
<div className='poll__footer'>
|
||||
{options.size < 4 && (
|
||||
<button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
|
||||
)}
|
||||
<button disabled={options.size >= 4} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
|
||||
|
||||
<select value={expiresIn} onChange={this.handleSelectDuration}>
|
||||
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
|
||||
|
@ -3,7 +3,7 @@ import UploadButton from '../components/upload_button';
|
||||
import { uploadCompose } from '../../../actions/compose';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
|
||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
|
||||
unavailable: state.getIn(['compose', 'poll']) !== null,
|
||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
||||
});
|
||||
|
@ -12,6 +12,7 @@ import IconButton from 'mastodon/components/icon_button';
|
||||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
@ -158,7 +159,7 @@ class Conversation extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className='conversation focusable muted' tabIndex='0'>
|
||||
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex='0'>
|
||||
<div className='conversation__avatar'>
|
||||
<AvatarComposite accounts={accounts} size={48} />
|
||||
</div>
|
||||
@ -166,7 +167,7 @@ class Conversation extends ImmutablePureComponent {
|
||||
<div className='conversation__content'>
|
||||
<div className='conversation__content__info'>
|
||||
<div className='conversation__content__relative-time'>
|
||||
<RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||
</div>
|
||||
|
||||
<div className='conversation__content__names' ref={this.setNamesRef}>
|
||||
|
@ -22,6 +22,7 @@ const messages = defineMessages({
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
|
@ -0,0 +1,436 @@
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
|
||||
import { autoPlayGif, reduceMotion } from 'mastodon/initial_state';
|
||||
import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
|
||||
import { mascot } from 'mastodon/initial_state';
|
||||
import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
|
||||
import classNames from 'classnames';
|
||||
import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
|
||||
import AnimatedNumber from 'mastodon/components/animated_number';
|
||||
import TransitionMotion from 'react-motion/lib/TransitionMotion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
});
|
||||
|
||||
class Content extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
announcement: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this._updateLinks();
|
||||
this._updateEmojis();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
this._updateLinks();
|
||||
this._updateEmojis();
|
||||
}
|
||||
|
||||
_updateEmojis () {
|
||||
const node = this.node;
|
||||
|
||||
if (!node || autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = node.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
|
||||
if (emoji.classList.contains('status-emoji')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
emoji.classList.add('status-emoji');
|
||||
|
||||
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
|
||||
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
|
||||
}
|
||||
}
|
||||
|
||||
_updateLinks () {
|
||||
const node = this.node;
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = node.querySelectorAll('a');
|
||||
|
||||
for (var i = 0; i < links.length; ++i) {
|
||||
let link = links[i];
|
||||
|
||||
if (link.classList.contains('status-link')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
link.classList.add('status-link');
|
||||
|
||||
let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
link.setAttribute('title', mention.get('acct'));
|
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
link.classList.add('unhandled-link');
|
||||
}
|
||||
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
onMentionClick = (mention, e) => {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${mention.get('id')}`);
|
||||
}
|
||||
}
|
||||
|
||||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '');
|
||||
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/timelines/tag/${hashtag}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleEmojiMouseEnter = ({ target }) => {
|
||||
target.src = target.getAttribute('data-original');
|
||||
}
|
||||
|
||||
handleEmojiMouseLeave = ({ target }) => {
|
||||
target.src = target.getAttribute('data-static');
|
||||
}
|
||||
|
||||
render () {
|
||||
const { announcement } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='announcements__item__content'
|
||||
ref={this.setRef}
|
||||
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
class Emoji extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.string.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
hovered: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emoji, emojiMap, hovered } = this.props;
|
||||
|
||||
if (unicodeMapping[emoji]) {
|
||||
const { filename, shortCode } = unicodeMapping[this.props.emoji];
|
||||
const title = shortCode ? `:${shortCode}:` : '';
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione'
|
||||
alt={emoji}
|
||||
title={title}
|
||||
src={`${assetHost}/emoji/${filename}.svg`}
|
||||
/>
|
||||
);
|
||||
} else if (emojiMap.get(emoji)) {
|
||||
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
|
||||
const shortCode = `:${emoji}:`;
|
||||
|
||||
return (
|
||||
<img
|
||||
draggable='false'
|
||||
className='emojione custom-emoji'
|
||||
alt={shortCode}
|
||||
title={shortCode}
|
||||
src={filename}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Reaction extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
announcementId: PropTypes.string.isRequired,
|
||||
reaction: ImmutablePropTypes.map.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
style: PropTypes.object,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovered: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { reaction, announcementId, addReaction, removeReaction } = this.props;
|
||||
|
||||
if (reaction.get('me')) {
|
||||
removeReaction(announcementId, reaction.get('name'));
|
||||
} else {
|
||||
addReaction(announcementId, reaction.get('name'));
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = () => this.setState({ hovered: true })
|
||||
|
||||
handleMouseLeave = () => this.setState({ hovered: false })
|
||||
|
||||
render () {
|
||||
const { reaction } = this.props;
|
||||
|
||||
let shortCode = reaction.get('name');
|
||||
|
||||
if (unicodeMapping[shortCode]) {
|
||||
shortCode = unicodeMapping[shortCode].shortCode;
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
||||
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
||||
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ReactionsBar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
announcementId: PropTypes.string.isRequired,
|
||||
reactions: ImmutablePropTypes.list.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
handleEmojiPick = data => {
|
||||
const { addReaction, announcementId } = this.props;
|
||||
addReaction(announcementId, data.native.replace(/:/g, ''));
|
||||
}
|
||||
|
||||
willEnter () {
|
||||
return { scale: reduceMotion ? 1 : 0 };
|
||||
}
|
||||
|
||||
willLeave () {
|
||||
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
|
||||
}
|
||||
|
||||
render () {
|
||||
const { reactions } = this.props;
|
||||
const visibleReactions = reactions.filter(x => x.get('count') > 0);
|
||||
|
||||
const styles = visibleReactions.map(reaction => ({
|
||||
key: reaction.get('name'),
|
||||
data: reaction,
|
||||
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
|
||||
})).toArray();
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
|
||||
{items => (
|
||||
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<Reaction
|
||||
key={key}
|
||||
reaction={data}
|
||||
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
|
||||
announcementId={this.props.announcementId}
|
||||
addReaction={this.props.addReaction}
|
||||
removeReaction={this.props.removeReaction}
|
||||
emojiMap={this.props.emojiMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
|
||||
</div>
|
||||
)}
|
||||
</TransitionMotion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Announcement extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
announcement: ImmutablePropTypes.map.isRequired,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
selected: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
unread: !this.props.announcement.get('read'),
|
||||
};
|
||||
|
||||
componentDidUpdate () {
|
||||
const { selected, announcement } = this.props;
|
||||
if (!selected && this.state.unread !== !announcement.get('read')) {
|
||||
this.setState({ unread: !announcement.get('read') });
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { announcement } = this.props;
|
||||
const { unread } = this.state;
|
||||
const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
|
||||
const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
|
||||
const now = new Date();
|
||||
const hasTimeRange = startsAt && endsAt;
|
||||
const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
|
||||
const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
|
||||
const skipTime = announcement.get('all_day');
|
||||
|
||||
return (
|
||||
<div className='announcements__item'>
|
||||
<strong className='announcements__item__range'>
|
||||
<FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
|
||||
{hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
|
||||
</strong>
|
||||
|
||||
<Content announcement={announcement} />
|
||||
|
||||
<ReactionsBar
|
||||
reactions={announcement.get('reactions')}
|
||||
announcementId={announcement.get('id')}
|
||||
addReaction={this.props.addReaction}
|
||||
removeReaction={this.props.removeReaction}
|
||||
emojiMap={this.props.emojiMap}
|
||||
/>
|
||||
|
||||
{unread && <span className='announcements__item__unread' />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default @injectIntl
|
||||
class Announcements extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
announcements: ImmutablePropTypes.list,
|
||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||
dismissAnnouncement: PropTypes.func.isRequired,
|
||||
addReaction: PropTypes.func.isRequired,
|
||||
removeReaction: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
index: 0,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._markAnnouncementAsRead();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
this._markAnnouncementAsRead();
|
||||
}
|
||||
|
||||
_markAnnouncementAsRead () {
|
||||
const { dismissAnnouncement, announcements } = this.props;
|
||||
const { index } = this.state;
|
||||
const announcement = announcements.get(index);
|
||||
if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
|
||||
}
|
||||
|
||||
handleChangeIndex = index => {
|
||||
this.setState({ index: index % this.props.announcements.size });
|
||||
}
|
||||
|
||||
handleNextClick = () => {
|
||||
this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
|
||||
}
|
||||
|
||||
handlePrevClick = () => {
|
||||
this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { announcements, intl } = this.props;
|
||||
const { index } = this.state;
|
||||
|
||||
if (announcements.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='announcements'>
|
||||
<img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||
|
||||
<div className='announcements__container'>
|
||||
<ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
|
||||
{announcements.map((announcement, idx) => (
|
||||
<Announcement
|
||||
key={announcement.get('id')}
|
||||
announcement={announcement}
|
||||
emojiMap={this.props.emojiMap}
|
||||
addReaction={this.props.addReaction}
|
||||
removeReaction={this.props.removeReaction}
|
||||
intl={intl}
|
||||
selected={index === idx}
|
||||
/>
|
||||
))}
|
||||
</ReactSwipeableViews>
|
||||
|
||||
{announcements.size > 1 && (
|
||||
<div className='announcements__pagination'>
|
||||
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
|
||||
<span>{index + 1} / {announcements.size}</span>
|
||||
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { addReaction, removeReaction, dismissAnnouncement } from 'mastodon/actions/announcements';
|
||||
import Announcements from '../components/announcements';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
announcements: state.getIn(['announcements', 'items']),
|
||||
emojiMap: customEmojiMap(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
|
||||
addReaction: (id, name) => dispatch(addReaction(id, name)),
|
||||
removeReaction: (id, name) => dispatch(removeReaction(id, name)),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Announcements);
|
@ -1,5 +1,5 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchTrends } from '../../../actions/trends';
|
||||
import { fetchTrends } from 'mastodon/actions/trends';
|
||||
import Trends from '../components/trends';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
|
@ -22,6 +22,7 @@ const messages = defineMessages({
|
||||
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
|
||||
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
@ -126,11 +127,12 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
navItems.push(
|
||||
<ColumnLink key={i++} icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
|
||||
<ColumnLink key={i++} icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
|
||||
<ColumnLink key={i++} icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
||||
<ColumnLink key={i++} icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />
|
||||
);
|
||||
|
||||
height += 48*3;
|
||||
height += 48*4;
|
||||
|
||||
if (myAccount.get('locked') || unreadFollowRequests > 0) {
|
||||
navItems.push(<ColumnLink key={i++} icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
|
@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Toggle from 'react-toggle';
|
||||
import AsyncSelect from 'react-select/lib/Async';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
|
||||
|
@ -9,14 +9,23 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||
import classNames from 'classnames';
|
||||
import IconWithBadge from 'mastodon/components/icon_with_badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||
show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
|
||||
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
||||
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
||||
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
||||
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
|
||||
showAnnouncements: state.getIn(['announcements', 'show']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@ -31,6 +40,9 @@ class HomeTimeline extends React.PureComponent {
|
||||
isPartial: PropTypes.bool,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
hasAnnouncements: PropTypes.bool,
|
||||
unreadAnnouncements: PropTypes.number,
|
||||
showAnnouncements: PropTypes.bool,
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
@ -61,6 +73,7 @@ class HomeTimeline extends React.PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.props.dispatch(fetchAnnouncements());
|
||||
this._checkIfReloadNeeded(false, this.props.isPartial);
|
||||
}
|
||||
|
||||
@ -93,10 +106,31 @@ class HomeTimeline extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleAnnouncementsClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.props.dispatch(toggleShowAnnouncements());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
|
||||
const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
let announcementsButton = null;
|
||||
|
||||
if (hasAnnouncements) {
|
||||
announcementsButton = (
|
||||
<button
|
||||
className={classNames('column-header__button', { 'active': showAnnouncements })}
|
||||
title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
|
||||
aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
|
||||
aria-pressed={showAnnouncements ? 'true' : 'false'}
|
||||
onClick={this.handleToggleAnnouncementsClick}
|
||||
>
|
||||
<IconWithBadge id='bullhorn' count={unreadAnnouncements} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
@ -108,6 +142,8 @@ class HomeTimeline extends React.PureComponent {
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={announcementsButton}
|
||||
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
||||
>
|
||||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
@ -56,6 +56,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>e</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>x</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
|
||||
|
@ -57,6 +57,17 @@ export default class ColumnSettings extends React.PureComponent {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-follow-request'>
|
||||
<span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-favourite'>
|
||||
<span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
|
||||
|
||||
|
@ -0,0 +1,59 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import DisplayName from 'mastodon/components/display_name';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const messages = defineMessages({
|
||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class FollowRequest extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onAuthorize: PropTypes.func.isRequired,
|
||||
onReject: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, hidden, account, onAuthorize, onReject } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<Fragment>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div className='account__relationship'>
|
||||
<IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />
|
||||
<IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,13 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import StatusContainer from '../../../containers/status_container';
|
||||
import AccountContainer from '../../../containers/account_container';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import AccountContainer from 'mastodon/containers/account_container';
|
||||
import FollowRequestContainer from '../containers/follow_request_container';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
|
||||
const messages = defineMessages({
|
||||
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
|
||||
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
|
||||
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
|
||||
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
|
||||
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
|
||||
});
|
||||
|
||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||
const output = [message];
|
||||
@ -107,7 +117,7 @@ class Notification extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow', defaultMessage: '{name} followed you' }, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='user-plus' fixedWidth />
|
||||
@ -118,7 +128,29 @@ class Notification extends ImmutablePureComponent {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
||||
<AccountContainer id={account.get('id')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderFollowRequest (notification, account, link) {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-follow-request focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='user' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.follow_request' defaultMessage='{name} has requested to follow you' values={{ name: link }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FollowRequestContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
@ -146,7 +178,7 @@ class Notification extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='star' className='star-icon' fixedWidth />
|
||||
@ -178,7 +210,7 @@ class Notification extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='retweet' fixedWidth />
|
||||
@ -205,25 +237,31 @@ class Notification extends ImmutablePureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
renderPoll (notification) {
|
||||
renderPoll (notification, account) {
|
||||
const { intl } = this.props;
|
||||
const ownPoll = me === account.get('id');
|
||||
const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }), notification.get('created_at'))}>
|
||||
<div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='tasks' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
|
||||
{ownPoll ? (
|
||||
<FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' />
|
||||
) : (
|
||||
<FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusContainer
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
account={account}
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
@ -246,6 +284,8 @@ class Notification extends ImmutablePureComponent {
|
||||
switch(notification.get('type')) {
|
||||
case 'follow':
|
||||
return this.renderFollow(notification, account, link);
|
||||
case 'follow_request':
|
||||
return this.renderFollowRequest(notification, account, link);
|
||||
case 'mention':
|
||||
return this.renderMention(notification);
|
||||
case 'favourite':
|
||||
@ -253,7 +293,7 @@ class Notification extends ImmutablePureComponent {
|
||||
case 'reblog':
|
||||
return this.renderReblog(notification, link);
|
||||
case 'poll':
|
||||
return this.renderPoll(notification);
|
||||
return this.renderPoll(notification, account);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -0,0 +1,26 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import FollowRequest from '../components/follow_request';
|
||||
import { authorizeFollowRequest, rejectFollowRequest } from 'mastodon/actions/accounts';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, props.id),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
onAuthorize () {
|
||||
dispatch(authorizeFollowRequest(id));
|
||||
},
|
||||
|
||||
onReject () {
|
||||
dispatch(rejectFollowRequest(id));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(FollowRequest);
|
@ -14,14 +14,16 @@ const messages = defineMessages({
|
||||
title: { id: 'column.public', defaultMessage: 'Federated timeline' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { onlyMedia, columnId }) => {
|
||||
const mapStateToProps = (state, { columnId }) => {
|
||||
const uuid = columnId;
|
||||
const columns = state.getIn(['settings', 'columns']);
|
||||
const index = columns.findIndex(c => c.get('uuid') === uuid);
|
||||
const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']);
|
||||
const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]);
|
||||
|
||||
return {
|
||||
hasUnread: state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
|
||||
onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']),
|
||||
hasUnread: !!timelineState && timelineState.get('unread') > 0,
|
||||
onlyMedia,
|
||||
};
|
||||
};
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user