Merge remote-tracking branch 'glitchsoc/master'
commit
eaca3d253b
|
@ -64,12 +64,17 @@ aliases:
|
|||
|
||||
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
|
||||
- *restore_ruby_dependencies
|
||||
- run: bundle install --clean --jobs 16 --path ./vendor/bundle/ --retry 3 --with pam_authentication --without development production
|
||||
- run: bundle install --clean --jobs 16 --path ./vendor/bundle/ --retry 3 --with pam_authentication --without development production && bundle clean
|
||||
- save_cache:
|
||||
key: v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
|
||||
paths:
|
||||
- ./.bundle/
|
||||
- ./vendor/bundle/
|
||||
- persist_to_workspace:
|
||||
root: ~/projects/
|
||||
paths:
|
||||
- ./mastodon/.bundle/
|
||||
- ./mastodon/vendor/bundle/
|
||||
|
||||
- &test_steps
|
||||
steps:
|
||||
|
@ -78,9 +83,6 @@ aliases:
|
|||
- *install_system_dependencies
|
||||
- run: sudo apt-get install -y ffmpeg
|
||||
|
||||
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
|
||||
- *restore_ruby_dependencies
|
||||
|
||||
- run:
|
||||
name: Prepare Tests
|
||||
command: ./bin/rails parallel:create parallel:load_schema parallel:prepare
|
||||
|
@ -116,8 +118,6 @@ jobs:
|
|||
steps:
|
||||
- *attach_workspace
|
||||
- *install_system_dependencies
|
||||
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
|
||||
- *restore_ruby_dependencies
|
||||
- run:
|
||||
name: Precompile assets
|
||||
command: ./bin/rails assets:precompile
|
||||
|
@ -173,8 +173,6 @@ jobs:
|
|||
<<: *defaults
|
||||
steps:
|
||||
- *attach_workspace
|
||||
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
|
||||
- *restore_ruby_dependencies
|
||||
- run: bundle exec i18n-tasks check-normalized
|
||||
- run: bundle exec i18n-tasks unused
|
||||
|
||||
|
@ -189,9 +187,11 @@ workflows:
|
|||
- install-ruby2.4:
|
||||
requires:
|
||||
- install
|
||||
- install-ruby2.5
|
||||
- install-ruby2.3:
|
||||
requires:
|
||||
- install
|
||||
- install-ruby2.5
|
||||
- build:
|
||||
requires:
|
||||
- install-ruby2.5
|
||||
|
|
|
@ -165,6 +165,7 @@ STREAMING_CLUSTER_NUM=1
|
|||
# LDAP_BIND_DN=
|
||||
# LDAP_PASSWORD=
|
||||
# LDAP_UID=cn
|
||||
# LDAP_SEARCH_FILTER="%{uid}=%{email}"
|
||||
|
||||
# PAM authentication (optional)
|
||||
# PAM authentication uses for the email generation the "email" pam variable
|
||||
|
|
|
@ -9,4 +9,4 @@ about: Create a report to help us improve
|
|||
* * * *
|
||||
|
||||
- [ ] I searched or browsed the repo’s other issues to ensure this is not a duplicate.
|
||||
- [ ] This bug happens on a [tagged release](https://github.com/tootsuite/mastodon/releases) and not on `master` (If you're a user, don't worry about this).
|
||||
- [ ] This bugs also occur on vanilla Mastodon
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -10,12 +10,12 @@ gem 'rails', '~> 5.2.1'
|
|||
|
||||
gem 'hamlit-rails', '~> 0.2'
|
||||
gem 'pg', '~> 1.0'
|
||||
gem 'makara', '~> 0.4'
|
||||
gem 'pghero', '~> 2.1'
|
||||
gem 'dotenv-rails', '~> 2.2', '< 2.3'
|
||||
|
||||
gem 'aws-sdk-s3', '~> 1.9', require: false
|
||||
gem 'fog-core', '~> 1.45'
|
||||
gem 'fog-local', '~> 0.5', require: false
|
||||
gem 'fog-openstack', '~> 0.1', require: false
|
||||
gem 'paperclip', '~> 6.0'
|
||||
gem 'paperclip-av-transcoder', '~> 0.6'
|
||||
|
@ -41,7 +41,7 @@ gem 'omniauth-cas', '~> 1.1'
|
|||
gem 'omniauth-saml', '~> 1.10'
|
||||
gem 'omniauth', '~> 1.2'
|
||||
|
||||
gem 'doorkeeper', '~> 4.2', '< 4.3'
|
||||
gem 'doorkeeper', '~> 4.4'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'fastimage'
|
||||
gem 'goldfinger', '~> 2.1'
|
||||
|
|
10
Gemfile.lock
10
Gemfile.lock
|
@ -181,7 +181,7 @@ GEM
|
|||
docile (1.3.0)
|
||||
domain_name (0.5.20180417)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
doorkeeper (4.2.6)
|
||||
doorkeeper (4.4.2)
|
||||
railties (>= 4.2)
|
||||
dotenv (2.2.2)
|
||||
dotenv-rails (2.2.2)
|
||||
|
@ -220,8 +220,6 @@ GEM
|
|||
fog-json (1.0.2)
|
||||
fog-core (~> 1.0)
|
||||
multi_json (~> 1.10)
|
||||
fog-local (0.5.0)
|
||||
fog-core (>= 1.27, < 3.0)
|
||||
fog-openstack (0.1.25)
|
||||
fog-core (~> 1.40)
|
||||
fog-json (>= 1.0)
|
||||
|
@ -324,6 +322,8 @@ GEM
|
|||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.0)
|
||||
mini_mime (>= 0.1.1)
|
||||
makara (0.4.0)
|
||||
activerecord (>= 3.0.0)
|
||||
marcel (0.3.2)
|
||||
mimemagic (~> 0.3.2)
|
||||
mario-redis-lock (1.2.1)
|
||||
|
@ -672,14 +672,13 @@ DEPENDENCIES
|
|||
devise (~> 4.4)
|
||||
devise-two-factor (~> 3.0)
|
||||
devise_pam_authenticatable2 (~> 9.1)
|
||||
doorkeeper (~> 4.2, < 4.3)
|
||||
doorkeeper (~> 4.4)
|
||||
dotenv-rails (~> 2.2, < 2.3)
|
||||
fabrication (~> 2.20)
|
||||
faker (~> 1.8)
|
||||
fast_blank (~> 1.0)
|
||||
fastimage
|
||||
fog-core (~> 1.45)
|
||||
fog-local (~> 0.5)
|
||||
fog-openstack (~> 0.1)
|
||||
fuubar (~> 2.2)
|
||||
goldfinger (~> 2.1)
|
||||
|
@ -700,6 +699,7 @@ DEPENDENCIES
|
|||
letter_opener_web (~> 1.3)
|
||||
link_header (~> 0.0)
|
||||
lograge (~> 0.10)
|
||||
makara (~> 0.4)
|
||||
mario-redis-lock (~> 1.2)
|
||||
memory_profiler
|
||||
microformats (~> 4.0)
|
||||
|
|
|
@ -31,7 +31,7 @@ class StatusesIndex < Chewy::Index
|
|||
},
|
||||
}
|
||||
|
||||
define_type ::Status.without_reblogs do
|
||||
define_type ::Status.unscoped.without_reblogs do
|
||||
crutch :mentions do |collection|
|
||||
data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id)
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
|
|
|
@ -43,7 +43,7 @@ class AccountsController < ApplicationController
|
|||
format.json do
|
||||
skip_session!
|
||||
|
||||
render_cached_json(['activitypub', 'actor', @account.cache_key], content_type: 'application/activity+json') do
|
||||
render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do
|
||||
ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -30,6 +30,12 @@ module Admin
|
|||
redirect_to admin_invites_path
|
||||
end
|
||||
|
||||
def deactivate_all
|
||||
authorize :invite, :deactivate_all?
|
||||
Invite.available.in_batches.update_all(expires_at: Time.now.utc)
|
||||
redirect_to admin_invites_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
|
|
|
@ -44,14 +44,8 @@ module Admin
|
|||
when 'resolve'
|
||||
@report.resolve!(current_account)
|
||||
log_action :resolve, @report
|
||||
when 'suspend'
|
||||
Admin::SuspensionWorker.perform_async(@report.target_account.id)
|
||||
|
||||
log_action :resolve, @report
|
||||
log_action :suspend, @report.target_account
|
||||
|
||||
resolve_all_target_account_reports
|
||||
when 'silence'
|
||||
@report.resolve!(current_account)
|
||||
@report.target_account.update!(silenced: true)
|
||||
|
||||
log_action :resolve, @report
|
||||
|
|
|
@ -28,6 +28,10 @@ module Admin
|
|||
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
|
||||
|
||||
redirect_to admin_account_statuses_path(@account.id, current_params)
|
||||
rescue ActionController::ParameterMissing
|
||||
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
|
||||
|
||||
redirect_to admin_account_statuses_path(@account.id, current_params)
|
||||
end
|
||||
|
||||
|
|
|
@ -4,11 +4,24 @@ module Admin
|
|||
class SuspensionsController < BaseController
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@suspension = Form::AdminSuspensionConfirmation.new(report_id: params[:report_id])
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @account, :suspend?
|
||||
Admin::SuspensionWorker.perform_async(@account.id)
|
||||
log_action :suspend, @account
|
||||
redirect_to admin_accounts_path
|
||||
|
||||
@suspension = Form::AdminSuspensionConfirmation.new(suspension_params)
|
||||
|
||||
if suspension_params[:acct] == @account.acct
|
||||
resolve_report! if suspension_params[:report_id]
|
||||
perform_suspend!
|
||||
mark_reports_resolved!
|
||||
redirect_to admin_accounts_path
|
||||
else
|
||||
flash.now[:alert] = I18n.t('admin.suspensions.bad_acct_msg')
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -23,5 +36,25 @@ module Admin
|
|||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def suspension_params
|
||||
params.require(:form_admin_suspension_confirmation).permit(:acct, :report_id)
|
||||
end
|
||||
|
||||
def resolve_report!
|
||||
report = Report.find(suspension_params[:report_id])
|
||||
report.resolve!(current_account)
|
||||
log_action :resolve, report
|
||||
end
|
||||
|
||||
def perform_suspend!
|
||||
@account.suspend!
|
||||
Admin::SuspensionWorker.perform_async(@account.id)
|
||||
log_action :suspend, @account
|
||||
end
|
||||
|
||||
def mark_reports_resolved!
|
||||
Report.where(target_account: @account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::EndorsementsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
|
||||
before_action :require_user!
|
||||
after_action :insert_pagination_headers
|
||||
|
||||
respond_to :json
|
||||
|
||||
def index
|
||||
@accounts = load_accounts
|
||||
render json: @accounts, each_serializer: REST::AccountSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_accounts
|
||||
if unlimited?
|
||||
endorsed_accounts.all
|
||||
else
|
||||
endorsed_accounts.paginate_by_max_id(
|
||||
limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
||||
params[:max_id],
|
||||
params[:since_id]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def endorsed_accounts
|
||||
current_account.endorsed_accounts
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
||||
def next_path
|
||||
return if unlimited?
|
||||
|
||||
if records_continue?
|
||||
api_v1_endorsements_url pagination_params(max_id: pagination_max_id)
|
||||
end
|
||||
end
|
||||
|
||||
def prev_path
|
||||
return if unlimited?
|
||||
|
||||
unless @accounts.empty?
|
||||
api_v1_endorsements_url pagination_params(since_id: pagination_since_id)
|
||||
end
|
||||
end
|
||||
|
||||
def pagination_max_id
|
||||
@accounts.last.id
|
||||
end
|
||||
|
||||
def pagination_since_id
|
||||
@accounts.first.id
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.slice(:limit).permit(:limit).merge(core_params)
|
||||
end
|
||||
|
||||
def unlimited?
|
||||
params[:limit] == '0'
|
||||
end
|
||||
end
|
|
@ -17,8 +17,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
CONTEXT_LIMIT = 4_096
|
||||
|
||||
def show
|
||||
cached = Rails.cache.read(@status.cache_key)
|
||||
@status = cached unless cached.nil?
|
||||
@status = cache_collection([@status], Status).first
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
|
|
|
@ -178,12 +178,8 @@ class ApplicationController < ActionController::Base
|
|||
return raw unless klass.respond_to?(:with_includes)
|
||||
|
||||
raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
|
||||
uncached_ids = []
|
||||
cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
|
||||
|
||||
raw.each do |item|
|
||||
uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key)
|
||||
end
|
||||
cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id)
|
||||
uncached_ids = raw.map(&:id) - cached_keys_with_value.keys
|
||||
|
||||
klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)
|
||||
|
||||
|
@ -191,11 +187,11 @@ class ApplicationController < ActionController::Base
|
|||
uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h
|
||||
|
||||
uncached.each_value do |item|
|
||||
Rails.cache.write(item.cache_key, item)
|
||||
Rails.cache.write(item, item)
|
||||
end
|
||||
end
|
||||
|
||||
raw.map { |item| cached_keys_with_value[item.cache_key] || uncached[item.id] }.compact
|
||||
raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
|
||||
end
|
||||
|
||||
def respond_with_error(code)
|
||||
|
@ -211,7 +207,6 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
def render_cached_json(cache_key, **options)
|
||||
options[:expires_in] ||= 3.minutes
|
||||
cache_key = cache_key.join(':') if cache_key.is_a?(Enumerable)
|
||||
cache_public = options.key?(:public) ? options.delete(:public) : true
|
||||
content_type = options.delete(:content_type) || 'application/json'
|
||||
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AuthorizeFollowsController < ApplicationController
|
||||
layout 'modal'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_pack
|
||||
before_action :set_body_classes
|
||||
|
||||
def show
|
||||
@account = located_account || render(:error)
|
||||
end
|
||||
|
||||
def create
|
||||
@account = follow_attempt.try(:target_account)
|
||||
|
||||
if @account.nil?
|
||||
render :error
|
||||
else
|
||||
render :success
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
render :error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_pack
|
||||
use_pack 'modal'
|
||||
end
|
||||
|
||||
def follow_attempt
|
||||
FollowService.new.call(current_account, acct_without_prefix)
|
||||
end
|
||||
|
||||
def located_account
|
||||
if acct_param_is_url?
|
||||
account_from_remote_fetch
|
||||
else
|
||||
account_from_remote_follow
|
||||
end
|
||||
end
|
||||
|
||||
def account_from_remote_fetch
|
||||
FetchRemoteAccountService.new.call(acct_without_prefix)
|
||||
end
|
||||
|
||||
def account_from_remote_follow
|
||||
ResolveAccountService.new.call(acct_without_prefix)
|
||||
end
|
||||
|
||||
def acct_param_is_url?
|
||||
parsed_uri.path && %w(http https).include?(parsed_uri.scheme)
|
||||
end
|
||||
|
||||
def parsed_uri
|
||||
Addressable::URI.parse(acct_without_prefix).normalize
|
||||
end
|
||||
|
||||
def acct_without_prefix
|
||||
acct_params.gsub(/\Aacct:/, '')
|
||||
end
|
||||
|
||||
def acct_params
|
||||
params.fetch(:acct, '')
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'modal-layout'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,71 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AuthorizeInteractionsController < ApplicationController
|
||||
include Authorization
|
||||
|
||||
layout 'modal'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_body_classes
|
||||
before_action :set_resource
|
||||
before_action :set_pack
|
||||
|
||||
def show
|
||||
if @resource.is_a?(Account)
|
||||
render :show
|
||||
elsif @resource.is_a?(Status)
|
||||
redirect_to web_url("statuses/#{@resource.id}")
|
||||
else
|
||||
render :error
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource)
|
||||
render :success
|
||||
else
|
||||
render :error
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
render :error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_resource
|
||||
@resource = located_resource || render(:error)
|
||||
authorize(@resource, :show?) if @resource.is_a?(Status)
|
||||
end
|
||||
|
||||
def located_resource
|
||||
if uri_param_is_url?
|
||||
ResolveURLService.new.call(uri_param)
|
||||
else
|
||||
account_from_remote_follow
|
||||
end
|
||||
end
|
||||
|
||||
def account_from_remote_follow
|
||||
ResolveAccountService.new.call(uri_param)
|
||||
end
|
||||
|
||||
def uri_param_is_url?
|
||||
parsed_uri.path && %w(http https).include?(parsed_uri.scheme)
|
||||
end
|
||||
|
||||
def parsed_uri
|
||||
Addressable::URI.parse(uri_param).normalize
|
||||
end
|
||||
|
||||
def uri_param
|
||||
params[:uri] || params.fetch(:acct, '').gsub(/\Aacct:/, '')
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'modal-layout'
|
||||
end
|
||||
|
||||
def set_pack
|
||||
use_pack 'modal'
|
||||
end
|
||||
end
|
|
@ -9,7 +9,7 @@ class EmojisController < ApplicationController
|
|||
format.json do
|
||||
skip_session!
|
||||
|
||||
render_cached_json(['activitypub', 'emoji', @emoji.cache_key], content_type: 'application/activity+json') do
|
||||
render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do
|
||||
ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ class IntentsController < ApplicationController
|
|||
if uri.scheme == 'web+mastodon'
|
||||
case uri.host
|
||||
when 'follow'
|
||||
return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
|
||||
return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].gsub(/\Aacct:/, ''))
|
||||
when 'share'
|
||||
return redirect_to share_path(text: uri.query_values['text'])
|
||||
end
|
||||
|
|
|
@ -47,5 +47,6 @@ class RemoteFollowController < ApplicationController
|
|||
|
||||
def set_body_classes
|
||||
@body_classes = 'modal-layout'
|
||||
@hide_header = true
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoteInteractionController < ApplicationController
|
||||
include Authorization
|
||||
|
||||
layout 'modal'
|
||||
|
||||
before_action :set_status
|
||||
before_action :set_body_classes
|
||||
before_action :set_pack
|
||||
|
||||
def new
|
||||
@remote_follow = RemoteFollow.new(session_params)
|
||||
end
|
||||
|
||||
def create
|
||||
@remote_follow = RemoteFollow.new(resource_params)
|
||||
|
||||
if @remote_follow.valid?
|
||||
session[:remote_follow] = @remote_follow.acct
|
||||
redirect_to @remote_follow.interact_address_for(@status)
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
params.require(:remote_follow).permit(:acct)
|
||||
end
|
||||
|
||||
def session_params
|
||||
{ acct: session[:remote_follow] }
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
# Reraise in order to get a 404
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
@body_classes = 'modal-layout'
|
||||
@hide_header = true
|
||||
end
|
||||
|
||||
def set_pack
|
||||
use_pack 'modal'
|
||||
end
|
||||
end
|
|
@ -19,6 +19,10 @@ class StatusesController < ApplicationController
|
|||
before_action :set_referrer_policy_header, only: [:show]
|
||||
before_action :set_cache_headers
|
||||
|
||||
content_security_policy only: :embed do |p|
|
||||
p.frame_ancestors(false)
|
||||
end
|
||||
|
||||
def show
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
|
@ -34,7 +38,7 @@ class StatusesController < ApplicationController
|
|||
format.json do
|
||||
skip_session! unless @stream_entry.hidden?
|
||||
|
||||
render_cached_json(['activitypub', 'note', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
|
||||
render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
|
||||
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
|
||||
end
|
||||
end
|
||||
|
@ -44,7 +48,7 @@ class StatusesController < ApplicationController
|
|||
def activity
|
||||
skip_session!
|
||||
|
||||
render_cached_json(['activitypub', 'activity', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
|
||||
render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
|
||||
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -33,8 +33,12 @@ module Admin::ActionLogsHelper
|
|||
when 'DomainBlock', 'EmailDomainBlock'
|
||||
link_to attributes['domain'], "https://#{attributes['domain']}"
|
||||
when 'Status'
|
||||
tmp_status = Status.new(attributes)
|
||||
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", TagManager.instance.url_for(tmp_status)
|
||||
tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
|
||||
if tmp_status.account
|
||||
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
|
||||
else
|
||||
I18n.t('admin.action_logs.deleted_status')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -38,4 +38,14 @@ module HomeHelper
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
def obscured_counter(count)
|
||||
if count <= 0
|
||||
0
|
||||
elsif count == 1
|
||||
1
|
||||
else
|
||||
'1+'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -73,8 +73,10 @@ module JsonLdHelper
|
|||
end
|
||||
end
|
||||
|
||||
def body_to_json(body)
|
||||
body.is_a?(String) ? Oj.load(body, mode: :strict) : body
|
||||
def body_to_json(body, compare_id: nil)
|
||||
json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body
|
||||
return if compare_id.present? && json['id'] != compare_id
|
||||
json
|
||||
rescue Oj::ParseError
|
||||
nil
|
||||
end
|
||||
|
|
|
@ -27,6 +27,7 @@ module SettingsHelper
|
|||
io: 'Ido',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
ka: 'ქართული',
|
||||
ko: '한국어',
|
||||
nl: 'Nederlands',
|
||||
no: 'Norsk',
|
||||
|
|
|
@ -19,7 +19,7 @@ module StreamEntriesHelper
|
|||
safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('settings.edit_profile')])
|
||||
end
|
||||
elsif current_account.following?(account) || current_account.requested?(account)
|
||||
link_to account_unfollow_path(account), class: 'button logo-button', data: { method: :post } do
|
||||
link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do
|
||||
safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')])
|
||||
end
|
||||
else
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
// This file will be loaded on admin pages, regardless of theme.
|
||||
|
||||
import { delegate } from 'rails-ujs';
|
||||
import { start } from '../mastodon/common';
|
||||
|
||||
start();
|
||||
|
||||
function handleDeleteStatus(event) {
|
||||
const [data] = event.detail;
|
||||
|
|
|
@ -37,3 +37,17 @@ delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
|
|||
|
||||
return false;
|
||||
});
|
||||
|
||||
delegate(document, '.modal-button', 'click', e => {
|
||||
e.preventDefault();
|
||||
|
||||
let href;
|
||||
|
||||
if (e.target.nodeName !== 'A') {
|
||||
href = e.target.parentNode.href;
|
||||
} else {
|
||||
href = e.target.href;
|
||||
}
|
||||
|
||||
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
});
|
||||
|
|
|
@ -72,6 +72,17 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
|
|||
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
|
||||
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
|
||||
|
||||
export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
|
||||
export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS';
|
||||
export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL';
|
||||
|
||||
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY';
|
||||
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR';
|
||||
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE';
|
||||
|
||||
export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET';
|
||||
|
||||
|
||||
export function fetchAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchRelationships([id]));
|
||||
|
@ -733,3 +744,76 @@ export function unpinAccountFail(error) {
|
|||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedAccounts() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchPinnedAccountsRequest());
|
||||
|
||||
api(getState).get(`/api/v1/endorsements`, { params: { limit: 0 } })
|
||||
.then(({ data }) => dispatch(fetchPinnedAccountsSuccess(data)))
|
||||
.catch(err => dispatch(fetchPinnedAccountsFail(err)));
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedAccountsRequest() {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedAccountsSuccess(accounts, next) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedAccountsFail(error) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedAccountsSuggestions(q) {
|
||||
return (dispatch, getState) => {
|
||||
const params = {
|
||||
q,
|
||||
resolve: false,
|
||||
limit: 4,
|
||||
following: true,
|
||||
};
|
||||
|
||||
api(getState).get('/api/v1/accounts/search', { params })
|
||||
.then(({ data }) => dispatch(fetchPinnedAccountsSuggestionsReady(q, data)));
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedAccountsSuggestionsReady(query, accounts) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
|
||||
query,
|
||||
accounts,
|
||||
};
|
||||
};
|
||||
|
||||
export function clearPinnedAccountsSuggestions() {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
|
||||
};
|
||||
};
|
||||
|
||||
export function changePinnedAccountsSuggestions(value) {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
|
||||
value,
|
||||
}
|
||||
};
|
||||
|
||||
export function resetPinnedAccountsEditor() {
|
||||
return {
|
||||
type: PINNED_ACCOUNTS_EDITOR_RESET,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -211,11 +211,11 @@ export function uploadCompose(files) {
|
|||
};
|
||||
};
|
||||
|
||||
export function changeUploadCompose(id, description) {
|
||||
export function changeUploadCompose(id, params) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(changeUploadComposeRequest());
|
||||
|
||||
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
|
||||
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
|
||||
dispatch(changeUploadComposeSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
|
|
|
@ -32,7 +32,7 @@ export function submitSearch() {
|
|||
|
||||
dispatch(fetchSearchRequest());
|
||||
|
||||
api(getState).get('/api/v1/search', {
|
||||
api(getState).get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
resolve: true,
|
||||
|
|
|
@ -72,7 +72,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
|
||||
if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
|
||||
params.since_id = timeline.getIn(['items', 0]);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { shortNumberFormat } from 'flavours/glitch/util/numbers';
|
||||
|
||||
const Hashtag = ({ hashtag }) => (
|
||||
<div className='trends__item'>
|
||||
<div className='trends__item__name'>
|
||||
<Link to={`/timelines/tag/${hashtag.get('name')}`}>
|
||||
#<span>{hashtag.get('name')}</span>
|
||||
</Link>
|
||||
|
||||
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
|
||||
</div>
|
||||
|
||||
<div className='trends__item__current'>
|
||||
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
|
||||
</div>
|
||||
|
||||
<div className='trends__item__sparkline'>
|
||||
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
|
||||
<SparklinesCurve style={{ fill: 'none' }} />
|
||||
</Sparklines>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Hashtag.propTypes = {
|
||||
hashtag: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
export default Hashtag;
|
|
@ -528,6 +528,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
{...other}
|
||||
status={status}
|
||||
account={status.get('account')}
|
||||
showReplyCount={settings.get('show_reply_count')}
|
||||
/>
|
||||
) : null}
|
||||
{notification ? (
|
||||
|
|
|
@ -33,6 +33,16 @@ const messages = defineMessages({
|
|||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
});
|
||||
|
||||
const obfuscatedCount = count => {
|
||||
if (count < 0) {
|
||||
return 0;
|
||||
} else if (count <= 1) {
|
||||
return count;
|
||||
} else {
|
||||
return '1+';
|
||||
}
|
||||
};
|
||||
|
||||
@injectIntl
|
||||
export default class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
|
@ -56,6 +66,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
showReplyCount: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -63,6 +74,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'showReplyCount',
|
||||
'withDismiss',
|
||||
]
|
||||
|
||||
|
@ -134,7 +146,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { status, intl, withDismiss } = this.props;
|
||||
const { status, intl, withDismiss, showReplyCount } = this.props;
|
||||
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
|
@ -188,9 +200,27 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
|
||||
let replyButton = (
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
disabled={anonymousAccess}
|
||||
title={replyTitle}
|
||||
icon={replyIcon}
|
||||
onClick={this.handleReplyClick}
|
||||
/>
|
||||
);
|
||||
if (showReplyCount) {
|
||||
replyButton = (
|
||||
<div className='status__action-bar__counter'>
|
||||
{replyButton}
|
||||
<span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
|
||||
{replyButton}
|
||||
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
{shareButton}
|
||||
|
|
|
@ -95,28 +95,71 @@ function mapStateToProps (state) {
|
|||
};
|
||||
|
||||
// Dispatch mapping.
|
||||
const mapDispatchToProps = {
|
||||
onCancelReply: cancelReplyCompose,
|
||||
onChangeAdvancedOption: changeComposeAdvancedOption,
|
||||
onChangeDescription: changeUploadCompose,
|
||||
onChangeSensitivity: changeComposeSensitivity,
|
||||
onChangeSpoilerText: changeComposeSpoilerText,
|
||||
onChangeSpoilerness: changeComposeSpoilerness,
|
||||
onChangeText: changeCompose,
|
||||
onChangeVisibility: changeComposeVisibility,
|
||||
onClearSuggestions: clearComposeSuggestions,
|
||||
onCloseModal: closeModal,
|
||||
onFetchSuggestions: fetchComposeSuggestions,
|
||||
onInsertEmoji: insertEmojiCompose,
|
||||
onMount: mountCompose,
|
||||
onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
|
||||
onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
|
||||
onSelectSuggestion: selectComposeSuggestion,
|
||||
onSubmit: submitCompose,
|
||||
onUndoUpload: undoUploadCompose,
|
||||
onUnmount: unmountCompose,
|
||||
onUpload: uploadCompose,
|
||||
};
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onCancelReply() {
|
||||
dispatch(cancelReplyCompose());
|
||||
},
|
||||
onChangeAdvancedOption(option, value) {
|
||||
dispatch(changeComposeAdvancedOption(option, value));
|
||||
},
|
||||
onChangeDescription(id, description) {
|
||||
dispatch(changeUploadCompose(id, { description }));
|
||||
},
|
||||
onChangeSensitivity() {
|
||||
dispatch(changeComposeSensitivity());
|
||||
},
|
||||
onChangeSpoilerText(text) {
|
||||
dispatch(changeComposeSpoilerText(text));
|
||||
},
|
||||
onChangeSpoilerness() {
|
||||
dispatch(changeComposeSpoilerness());
|
||||
},
|
||||
onChangeText(text) {
|
||||
dispatch(changeCompose(text));
|
||||
},
|
||||
onChangeVisibility(value) {
|
||||
dispatch(changeComposeVisibility(value));
|
||||
},
|
||||
onClearSuggestions() {
|
||||
dispatch(clearComposeSuggestions());
|
||||
},
|
||||
onCloseModal() {
|
||||
dispatch(closeModal());
|
||||
},
|
||||
onFetchSuggestions(token) {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
},
|
||||
onInsertEmoji(position, emoji) {
|
||||
dispatch(insertEmojiCompose(position, emoji));
|
||||
},
|
||||
onMount() {
|
||||
dispatch(mountCompose());
|
||||
},
|
||||
onOpenActionModal(props) {
|
||||
dispatch(openModal('ACTIONS', props));
|
||||
},
|
||||
onOpenDoodleModal() {
|
||||
dispatch(openModal('DOODLE', { noEsc: true }));
|
||||
},
|
||||
onOpenFocalPointModal(id) {
|
||||
dispatch(openModal('FOCAL_POINT', { id }));
|
||||
},
|
||||
onSelectSuggestion(position, token, suggestion) {
|
||||
dispatch(selectComposeSuggestion(position, token, suggestion));
|
||||
},
|
||||
onSubmit() {
|
||||
dispatch(submitCompose());
|
||||
},
|
||||
onUndoUpload(id) {
|
||||
dispatch(undoUploadCompose(id));
|
||||
},
|
||||
onUnmount() {
|
||||
dispatch(unmountCompose());
|
||||
},
|
||||
onUpload(files) {
|
||||
dispatch(uploadCompose(files));
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers.
|
||||
const handlers = {
|
||||
|
@ -194,6 +237,13 @@ const handlers = {
|
|||
this.textarea = textareaComponent.textarea;
|
||||
}
|
||||
},
|
||||
|
||||
// Sets a reference to the CW field.
|
||||
handleRefSpoilerText (spoilerComponent) {
|
||||
if (spoilerComponent) {
|
||||
this.spoilerText = spoilerComponent.spoilerText;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The component.
|
||||
|
@ -206,6 +256,7 @@ class Composer extends React.Component {
|
|||
|
||||
// Instance variables.
|
||||
this.textarea = null;
|
||||
this.spoilerText = null;
|
||||
}
|
||||
|
||||
// Tells our state the composer has been mounted.
|
||||
|
@ -234,6 +285,7 @@ class Composer extends React.Component {
|
|||
componentDidUpdate (prevProps) {
|
||||
const {
|
||||
textarea,
|
||||
spoilerText,
|
||||
} = this;
|
||||
const {
|
||||
focusDate,
|
||||
|
@ -265,6 +317,16 @@ class Composer extends React.Component {
|
|||
// Refocuses the textarea after submitting.
|
||||
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
|
||||
textarea.focus();
|
||||
} else if (this.props.spoiler !== prevProps.spoiler) {
|
||||
if (this.props.spoiler) {
|
||||
if (spoilerText) {
|
||||
spoilerText.focus();
|
||||
}
|
||||
} else {
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -276,6 +338,7 @@ class Composer extends React.Component {
|
|||
handleSelect,
|
||||
handleSubmit,
|
||||
handleRefTextarea,
|
||||
handleRefSpoilerText,
|
||||
} = this.handlers;
|
||||
const {
|
||||
acceptContentTypes,
|
||||
|
@ -299,6 +362,7 @@ class Composer extends React.Component {
|
|||
onFetchSuggestions,
|
||||
onOpenActionsModal,
|
||||
onOpenDoodleModal,
|
||||
onOpenFocalPointModal,
|
||||
onUndoUpload,
|
||||
onUpload,
|
||||
privacy,
|
||||
|
@ -334,6 +398,7 @@ class Composer extends React.Component {
|
|||
onChange={handleChangeSpoiler}
|
||||
onSubmit={handleSubmit}
|
||||
text={spoilerText}
|
||||
ref={handleRefSpoilerText}
|
||||
/>
|
||||
<ComposerTextarea
|
||||
advancedOptions={advancedOptions}
|
||||
|
@ -357,6 +422,7 @@ class Composer extends React.Component {
|
|||
intl={intl}
|
||||
media={media}
|
||||
onChangeDescription={onChangeDescription}
|
||||
onOpenFocalPointModal={onOpenFocalPointModal}
|
||||
onRemove={onUndoUpload}
|
||||
progress={progress}
|
||||
uploading={isUploading}
|
||||
|
|
|
@ -168,6 +168,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
|
|||
const computedClass = classNames('composer--options--dropdown', {
|
||||
active,
|
||||
open,
|
||||
top: placement === 'top',
|
||||
});
|
||||
|
||||
// The result.
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Package imports.
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
// Components.
|
||||
|
@ -89,7 +90,7 @@ export default class ComposerReply extends React.PureComponent {
|
|||
}
|
||||
|
||||
ComposerReply.propTypes = {
|
||||
status: PropTypes.map.isRequired,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onCancel: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -33,6 +33,10 @@ const handlers = {
|
|||
onSubmit();
|
||||
}
|
||||
},
|
||||
|
||||
handleRefSpoilerText (spoilerText) {
|
||||
this.spoilerText = spoilerText;
|
||||
},
|
||||
};
|
||||
|
||||
// The component.
|
||||
|
@ -46,7 +50,7 @@ export default class ComposerSpoiler extends React.PureComponent {
|
|||
|
||||
// Rendering.
|
||||
render () {
|
||||
const { handleKeyDown } = this.handlers;
|
||||
const { handleKeyDown, handleRefSpoilerText } = this.handlers;
|
||||
const {
|
||||
hidden,
|
||||
intl,
|
||||
|
@ -68,6 +72,7 @@ export default class ComposerSpoiler extends React.PureComponent {
|
|||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
type='text'
|
||||
value={text}
|
||||
ref={handleRefSpoilerText}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -13,6 +13,7 @@ export default function ComposerUploadForm ({
|
|||
intl,
|
||||
media,
|
||||
onChangeDescription,
|
||||
onOpenFocalPointModal,
|
||||
onRemove,
|
||||
progress,
|
||||
uploading,
|
||||
|
@ -31,8 +32,12 @@ export default function ComposerUploadForm ({
|
|||
key={item.get('id')}
|
||||
id={item.get('id')}
|
||||
intl={intl}
|
||||
focusX={item.getIn(['meta', 'focus', 'x'])}
|
||||
focusY={item.getIn(['meta', 'focus', 'y'])}
|
||||
mediaType={item.get('type')}
|
||||
preview={item.get('preview_url')}
|
||||
onChangeDescription={onChangeDescription}
|
||||
onOpenFocalPointModal={onOpenFocalPointModal}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
|
@ -46,8 +51,8 @@ export default function ComposerUploadForm ({
|
|||
ComposerUploadForm.propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
media: ImmutablePropTypes.list,
|
||||
onChangeDescription: PropTypes.func,
|
||||
onRemove: PropTypes.func,
|
||||
onChangeDescription: PropTypes.func.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
progress: PropTypes.number,
|
||||
uploading: PropTypes.bool,
|
||||
};
|
||||
|
|
|
@ -25,6 +25,10 @@ const messages = defineMessages({
|
|||
defaultMessage: 'Describe for the visually impaired',
|
||||
id: 'upload_form.description',
|
||||
},
|
||||
crop: {
|
||||
defaultMessage: 'Crop',
|
||||
id: 'upload_form.focus',
|
||||
},
|
||||
});
|
||||
|
||||
// Handlers.
|
||||
|
@ -37,11 +41,10 @@ const handlers = {
|
|||
onChangeDescription,
|
||||
} = this.props;
|
||||
const { dirtyDescription } = this.state;
|
||||
|
||||
this.setState({ dirtyDescription: null, focused: false });
|
||||
|
||||
if (id && onChangeDescription && dirtyDescription !== null) {
|
||||
this.setState({
|
||||
dirtyDescription: null,
|
||||
focused: false,
|
||||
});
|
||||
onChangeDescription(id, dirtyDescription);
|
||||
}
|
||||
},
|
||||
|
@ -77,6 +80,17 @@ const handlers = {
|
|||
onRemove(id);
|
||||
}
|
||||
},
|
||||
|
||||
// Opens the focal point modal.
|
||||
handleFocalPointClick () {
|
||||
const {
|
||||
id,
|
||||
onOpenFocalPointModal,
|
||||
} = this.props;
|
||||
if (id && onOpenFocalPointModal) {
|
||||
onOpenFocalPointModal(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// The component.
|
||||
|
@ -102,18 +116,25 @@ export default class ComposerUploadFormItem extends React.PureComponent {
|
|||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleRemove,
|
||||
handleFocalPointClick,
|
||||
} = this.handlers;
|
||||
const {
|
||||
description,
|
||||
intl,
|
||||
preview,
|
||||
focusX,
|
||||
focusY,
|
||||
mediaType,
|
||||
} = this.props;
|
||||
const {
|
||||
focused,
|
||||
hovered,
|
||||
dirtyDescription,
|
||||
} = this.state;
|
||||
const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
|
||||
const active = hovered || focused;
|
||||
const computedClass = classNames('composer--upload_form--item', { active });
|
||||
const x = ((focusX / 2) + .5) * 100;
|
||||
const y = ((focusY / -2) + .5) * 100;
|
||||
const description = dirtyDescription || (dirtyDescription !== '' && this.props.description) || '';
|
||||
|
||||
// The result.
|
||||
return (
|
||||
|
@ -136,15 +157,15 @@ export default class ComposerUploadFormItem extends React.PureComponent {
|
|||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
backgroundImage: preview ? `url(${preview})` : null,
|
||||
backgroundPosition: `${x}% ${y}%`
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
className='close'
|
||||
icon='times'
|
||||
onClick={handleRemove}
|
||||
size={36}
|
||||
title={intl.formatMessage(messages.undo)}
|
||||
/>
|
||||
<div className={classNames('composer--upload_form--actions', { active })}>
|
||||
<button className='icon-button' onClick={handleRemove}>
|
||||
<i className='fa fa-times' /> <FormattedMessage {...messages.undo} />
|
||||
</button>
|
||||
{mediaType === 'image' && <button className='icon-button' onClick={handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage {...messages.crop} /></button>}
|
||||
</div>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
|
||||
<input
|
||||
|
@ -154,7 +175,7 @@ export default class ComposerUploadFormItem extends React.PureComponent {
|
|||
onFocus={handleFocus}
|
||||
placeholder={intl.formatMessage(messages.description)}
|
||||
type='text'
|
||||
value={dirtyDescription || description || ''}
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
@ -171,7 +192,11 @@ ComposerUploadFormItem.propTypes = {
|
|||
description: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onChangeDescription: PropTypes.func,
|
||||
onRemove: PropTypes.func,
|
||||
onChangeDescription: PropTypes.func.isRequired,
|
||||
onOpenFocalPointModal: PropTypes.func.isRequired,
|
||||
onRemove: PropTypes.func.isRequired,
|
||||
focusX: PropTypes.number,
|
||||
focusY: PropTypes.number,
|
||||
mediaType: PropTypes.string,
|
||||
preview: PropTypes.string,
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Link } from 'react-router-dom';
|
|||
// Components.
|
||||
import AccountContainer from 'flavours/glitch/containers/account_container';
|
||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||
import Hashtag from 'flavours/glitch/components/hashtag';
|
||||
|
||||
// Utils.
|
||||
import Motion from 'flavours/glitch/util/optional_motion';
|
||||
|
@ -98,15 +99,7 @@ export default function DrawerResults ({
|
|||
<section>
|
||||
<h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
|
||||
|
||||
{hashtags.map(
|
||||
hashtag => (
|
||||
<Link
|
||||
className='hashtag'
|
||||
key={hashtag}
|
||||
to={`/timelines/tag/${hashtag}`}
|
||||
>#{hashtag}</Link>
|
||||
)
|
||||
)}
|
||||
{hashtags.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
@ -129,20 +129,20 @@ export default class GettingStarted extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) {
|
||||
navItems.push(<ColumnLink icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />);
|
||||
navItems.push(<ColumnLink key='5' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />);
|
||||
}
|
||||
|
||||
if (myAccount.get('locked')) {
|
||||
navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
}
|
||||
|
||||
navItems.push(<ColumnLink key='6' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
|
||||
navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
|
||||
|
||||
listItems = listItems.concat([
|
||||
<div key='7'>
|
||||
<ColumnLink key='8' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
|
||||
<div key='8'>
|
||||
<ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
|
||||
{lists.map(list =>
|
||||
<ColumnLink key={(8 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
|
||||
<ColumnLink key={(9 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
|
||||
)}
|
||||
</div>,
|
||||
]);
|
||||
|
|
|
@ -21,6 +21,7 @@ const messages = defineMessages({
|
|||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
|
||||
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
|
||||
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
|
||||
featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },
|
||||
});
|
||||
|
||||
@connect()
|
||||
|
@ -33,27 +34,33 @@ export default class gettingStartedMisc extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
openOnboardingModal = (e) => {
|
||||
e.preventDefault();
|
||||
this.props.dispatch(openModal('ONBOARDING'));
|
||||
}
|
||||
|
||||
openFeaturedAccountsModal = (e) => {
|
||||
this.props.dispatch(openModal('PINNED_ACCOUNTS_EDITOR'));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
let i = 1;
|
||||
|
||||
return (
|
||||
<Column icon='ellipsis-h' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
|
||||
<div className='scrollable'>
|
||||
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
|
||||
<ColumnLink key='19' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
|
||||
<ColumnLink key='20' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
|
||||
<ColumnLink key='21' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
|
||||
<ColumnLink key='22' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
|
||||
<ColumnLink icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
|
||||
<ColumnLink key='23' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
|
||||
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
|
||||
<ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
|
||||
<ColumnLink key='{i++}' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
|
||||
<ColumnLink key='{i++}' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
|
||||
<ColumnLink key='{i++}' icon='users' text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} />
|
||||
<ColumnLink key='{i++}' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
|
||||
<ColumnLink key='{i++}' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
|
||||
<ColumnLink key='{i++}' icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
|
||||
<ColumnLink key='{i++}' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
|
||||
<ColumnLink key='{i++}' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
|
||||
<ColumnLink key='{i++}' icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
|
|
|
@ -1,38 +1,17 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from 'flavours/glitch/components/avatar';
|
||||
import DisplayName from 'flavours/glitch/components/display_name';
|
||||
import IconButton from 'flavours/glitch/components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId, added }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
onRemove: () => dispatch(removeFromListEditor(accountId)),
|
||||
onAdd: () => dispatch(addToListEditor(accountId)),
|
||||
});
|
||||
|
||||
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
export default class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
|
@ -1,26 +1,12 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['listEditor', 'suggestions', 'value']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSubmit: value => dispatch(fetchListSuggestions(value)),
|
||||
onClear: () => dispatch(clearListSuggestions()),
|
||||
onChange: value => dispatch(changeListSuggestions(value)),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
export default class Search extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
|
||||
import Account from '../components/account';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId, added }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
onRemove: () => dispatch(removeFromListEditor(accountId)),
|
||||
onAdd: () => dispatch(addToListEditor(accountId)),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
|
||||
import Search from '../components/search';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['listEditor', 'suggestions', 'value']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSubmit: value => dispatch(fetchListSuggestions(value)),
|
||||
onClear: () => dispatch(clearListSuggestions()),
|
||||
onChange: value => dispatch(changeListSuggestions(value)),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));
|
|
@ -5,8 +5,8 @@ import { connect } from 'react-redux';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { setupListEditor, clearListSuggestions, resetListEditor } from 'flavours/glitch/actions/lists';
|
||||
import Account from './components/account';
|
||||
import Search from './components/search';
|
||||
import AccountContainer from './containers/account_container';
|
||||
import SearchContainer from './containers/search_container';
|
||||
import Motion from 'flavours/glitch/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
|
@ -56,11 +56,11 @@ export default class ListEditor extends ImmutablePureComponent {
|
|||
<div className='modal-root__modal list-editor'>
|
||||
<h4>{title}</h4>
|
||||
|
||||
<Search />
|
||||
<SearchContainer />
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner list-editor__accounts'>
|
||||
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
|
||||
{accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
|
||||
</div>
|
||||
|
||||
{showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
|
||||
|
@ -68,7 +68,7 @@ export default class ListEditor extends ImmutablePureComponent {
|
|||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) =>
|
||||
(<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
|
||||
{searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
|
||||
</div>)
|
||||
}
|
||||
</Motion>
|
||||
|
|
|
@ -35,33 +35,44 @@ export default class LocalSettingsPage extends React.PureComponent {
|
|||
<h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['layout']}
|
||||
id='mastodon-settings--layout'
|
||||
options={[
|
||||
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
|
||||
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
|
||||
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
|
||||
]}
|
||||
item={['show_reply_count']}
|
||||
id='mastodon-settings--reply-count'
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['stretch']}
|
||||
id='mastodon-settings--stretch'
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['navbar_under']}
|
||||
id='mastodon-settings--navbar_under'
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
|
||||
<FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' />
|
||||
</LocalSettingsPageItem>
|
||||
<section>
|
||||
<h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['layout']}
|
||||
id='mastodon-settings--layout'
|
||||
options={[
|
||||
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
|
||||
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
|
||||
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
|
||||
]}
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['stretch']}
|
||||
id='mastodon-settings--stretch'
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
|
||||
</LocalSettingsPageItem>
|
||||
<LocalSettingsPageItem
|
||||
settings={settings}
|
||||
item={['navbar_under']}
|
||||
id='mastodon-settings--navbar_under'
|
||||
onChange={onChange}
|
||||
>
|
||||
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
|
||||
</LocalSettingsPageItem>
|
||||
</section>
|
||||
<section>
|
||||
<h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2>
|
||||
<LocalSettingsPageItem
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import { pinAccount, unpinAccount } from 'flavours/glitch/actions/accounts';
|
||||
import Account from 'flavours/glitch/features/list_editor/components/account';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId, added }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
||||
onRemove: () => dispatch(unpinAccount(accountId)),
|
||||
onAdd: () => dispatch(pinAccount(accountId)),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import {
|
||||
fetchPinnedAccountsSuggestions,
|
||||
clearPinnedAccountsSuggestions,
|
||||
changePinnedAccountsSuggestions
|
||||
} from '../../../actions/accounts';
|
||||
import Search from 'flavours/glitch/features/list_editor/components/search';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)),
|
||||
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
|
||||
onChange: value => dispatch(changePinnedAccountsSuggestions(value)),
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/glitch/actions/accounts';
|
||||
import AccountContainer from './containers/account_container';
|
||||
import SearchContainer from './containers/search_container';
|
||||
import Motion from 'flavours/glitch/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']),
|
||||
searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onInitialize: () => dispatch(fetchPinnedAccounts()),
|
||||
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
|
||||
onReset: () => dispatch(resetPinnedAccountsEditor()),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
@injectIntl
|
||||
export default class PinnedAccountsEditor extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onInitialize: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onReset: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
accountIds: ImmutablePropTypes.list.isRequired,
|
||||
searchAccountIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { onInitialize } = this.props;
|
||||
onInitialize();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { onReset } = this.props;
|
||||
onReset();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { accountIds, searchAccountIds, onClear } = this.props;
|
||||
const showSearch = searchAccountIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal list-editor'>
|
||||
<h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4>
|
||||
|
||||
<SearchContainer />
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner list-editor__accounts'>
|
||||
{accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
|
||||
</div>
|
||||
|
||||
{showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
|
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) =>
|
||||
(<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
{searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
|
||||
</div>)
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -327,10 +327,10 @@ export default class Status extends ImmutablePureComponent {
|
|||
if (status && ancestorsIds && ancestorsIds.size > 0) {
|
||||
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
|
||||
|
||||
if (element) {
|
||||
window.requestAnimationFrame(() => {
|
||||
element.scrollIntoView(true);
|
||||
this._scrolledIntoView = true;
|
||||
}
|
||||
});
|
||||
this._scrolledIntoView = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,8 +22,13 @@ const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => {
|
|||
</Link>
|
||||
);
|
||||
} else {
|
||||
const handleOnClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return onClick(e);
|
||||
}
|
||||
return (
|
||||
<a onClick={onClick} className='column-link' role='button' tabIndex='0' data-method={method}>
|
||||
<a href='#' onClick={onClick && handleOnClick} className='column-link' tabIndex='0'>
|
||||
<i className={`fa fa-fw fa-${icon} column-link__icon`} />
|
||||
{text}
|
||||
{badgeElement}
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
import ImageLoader from './image_loader';
|
||||
import classNames from 'classnames';
|
||||
import { changeUploadCompose } from 'flavours/glitch/actions/compose';
|
||||
import { getPointerPosition } from 'flavours/glitch/features/video';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||
|
||||
onSave: (x, y) => {
|
||||
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@connect(mapStateToProps, mapDispatchToProps)
|
||||
export default class FocalPointModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
focusX: 0,
|
||||
focusY: 0,
|
||||
dragging: false,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.updatePositionFromMedia(this.props.media);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (this.props.media.get('id') !== nextProps.media.get('id')) {
|
||||
this.updatePositionFromMedia(nextProps.media);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
}
|
||||
|
||||
handleMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseMove);
|
||||
document.addEventListener('mouseup', this.handleMouseUp);
|
||||
|
||||
this.updatePosition(e);
|
||||
this.setState({ dragging: true });
|
||||
}
|
||||
|
||||
handleMouseMove = e => {
|
||||
this.updatePosition(e);
|
||||
}
|
||||
|
||||
handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
|
||||
this.setState({ dragging: false });
|
||||
this.props.onSave(this.state.focusX, this.state.focusY);
|
||||
}
|
||||
|
||||
updatePosition = e => {
|
||||
const { x, y } = getPointerPosition(this.node, e);
|
||||
const focusX = (x - .5) * 2;
|
||||
const focusY = (y - .5) * -2;
|
||||
|
||||
this.setState({ x, y, focusX, focusY });
|
||||
}
|
||||
|
||||
updatePositionFromMedia = media => {
|
||||
const focusX = media.getIn(['meta', 'focus', 'x']);
|
||||
const focusY = media.getIn(['meta', 'focus', 'y']);
|
||||
|
||||
if (focusX && focusY) {
|
||||
const x = (focusX / 2) + .5;
|
||||
const y = (focusY / -2) + .5;
|
||||
|
||||
this.setState({ x, y, focusX, focusY });
|
||||
} else {
|
||||
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media } = this.props;
|
||||
const { x, y, dragging } = this.state;
|
||||
|
||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = media.getIn(['meta', 'original', 'height']) || null;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal video-modal focal-point-modal'>
|
||||
<div className={classNames('focal-point', { dragging })} ref={this.setRef}>
|
||||
<ImageLoader
|
||||
previewSrc={media.get('preview_url')}
|
||||
src={media.get('url')}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
|
||||
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
|
||||
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -11,6 +11,7 @@ import BoostModal from './boost_modal';
|
|||
import FavouriteModal from './favourite_modal';
|
||||
import DoodleModal from './doodle_modal';
|
||||
import ConfirmationModal from './confirmation_modal';
|
||||
import FocalPointModal from './focal_point_modal';
|
||||
import {
|
||||
OnboardingModal,
|
||||
MuteModal,
|
||||
|
@ -18,6 +19,7 @@ import {
|
|||
SettingsModal,
|
||||
EmbedModal,
|
||||
ListEditor,
|
||||
PinnedAccountsEditor,
|
||||
} from 'flavours/glitch/util/async-components';
|
||||
|
||||
const MODAL_COMPONENTS = {
|
||||
|
@ -34,6 +36,8 @@ const MODAL_COMPONENTS = {
|
|||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
'LIST_EDITOR': ListEditor,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||
'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends React.PureComponent {
|
||||
|
|
|
@ -92,6 +92,7 @@ const keyMap = {
|
|||
goToProfile: 'g u',
|
||||
goToBlocked: 'g b',
|
||||
goToMuted: 'g m',
|
||||
goToRequests: 'g r',
|
||||
toggleSpoiler: 'x',
|
||||
};
|
||||
|
||||
|
@ -369,6 +370,10 @@ export default class UI extends React.Component {
|
|||
this.props.history.push('/mutes');
|
||||
}
|
||||
|
||||
handleHotkeyGoToRequests = () => {
|
||||
this.props.history.push('/follow_requests');
|
||||
}
|
||||
|
||||
render () {
|
||||
const { width, draggingOver } = this.state;
|
||||
const { children, layout, isWide, navbarUnder, dropdownMenuIsOpen } = this.props;
|
||||
|
@ -408,6 +413,7 @@ export default class UI extends React.Component {
|
|||
goToProfile: this.handleHotkeyGoToProfile,
|
||||
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||
goToMuted: this.handleHotkeyGoToMuted,
|
||||
goToRequests: this.handleHotkeyGoToRequests,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -160,6 +160,9 @@ export default class Video extends React.PureComponent {
|
|||
this.setState({ dragging: true });
|
||||
this.video.pause();
|
||||
this.handleMouseMove(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleMouseUp = () => {
|
||||
|
@ -176,8 +179,10 @@ export default class Video extends React.PureComponent {
|
|||
const { x } = getPointerPosition(this.seek, e);
|
||||
const currentTime = Math.floor(this.video.duration * x);
|
||||
|
||||
this.video.currentTime = currentTime;
|
||||
this.setState({ currentTime });
|
||||
if (!isNaN(currentTime)) {
|
||||
this.video.currentTime = currentTime;
|
||||
this.setState({ currentTime });
|
||||
}
|
||||
}, 60);
|
||||
|
||||
togglePlay = () => {
|
||||
|
@ -290,6 +295,15 @@ export default class Video extends React.PureComponent {
|
|||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
let preload;
|
||||
if (startTime || fullscreen || dragging) {
|
||||
preload = 'auto';
|
||||
} else if (detailed) {
|
||||
preload = 'metadata';
|
||||
} else {
|
||||
preload = 'none';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })}
|
||||
|
@ -304,7 +318,7 @@ export default class Video extends React.PureComponent {
|
|||
ref={this.setVideoRef}
|
||||
src={src}
|
||||
poster={preview}
|
||||
preload={startTime ? 'auto' : 'none'}
|
||||
preload={preload}
|
||||
loop
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
|
|
|
@ -6,6 +6,8 @@ import {
|
|||
FOLLOWING_EXPAND_SUCCESS,
|
||||
FOLLOW_REQUESTS_FETCH_SUCCESS,
|
||||
FOLLOW_REQUESTS_EXPAND_SUCCESS,
|
||||
PINNED_ACCOUNTS_FETCH_SUCCESS,
|
||||
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
|
||||
} from 'flavours/glitch/actions/accounts';
|
||||
import {
|
||||
BLOCKS_FETCH_SUCCESS,
|
||||
|
@ -141,6 +143,8 @@ export default function accounts(state = initialState, action) {
|
|||
case MUTES_EXPAND_SUCCESS:
|
||||
case LIST_ACCOUNTS_FETCH_SUCCESS:
|
||||
case LIST_EDITOR_SUGGESTIONS_READY:
|
||||
case PINNED_ACCOUNTS_FETCH_SUCCESS:
|
||||
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY:
|
||||
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
|
||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||
case SEARCH_FETCH_SUCCESS:
|
||||
|
|
|
@ -371,7 +371,7 @@ export default function compose(state = initialState, action) {
|
|||
.set('is_submitting', false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
if (item.get('id') === action.media.id) {
|
||||
return item.set('description', action.media.description);
|
||||
return fromJS(action.media);
|
||||
}
|
||||
|
||||
return item;
|
||||
|
|
|
@ -28,6 +28,7 @@ import custom_emojis from './custom_emojis';
|
|||
import lists from './lists';
|
||||
import listEditor from './list_editor';
|
||||
import filters from './filters';
|
||||
import pinnedAccountsEditor from './pinned_accounts_editor';
|
||||
|
||||
const reducers = {
|
||||
dropdown_menu,
|
||||
|
@ -59,6 +60,7 @@ const reducers = {
|
|||
lists,
|
||||
listEditor,
|
||||
filters,
|
||||
pinnedAccountsEditor,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
|
|
|
@ -11,6 +11,7 @@ const initialState = ImmutableMap({
|
|||
navbar_under : false,
|
||||
side_arm : 'none',
|
||||
side_arm_reply_mode : 'keep',
|
||||
show_reply_count : false,
|
||||
collapsed : ImmutableMap({
|
||||
enabled : true,
|
||||
auto : ImmutableMap({
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import {
|
||||
PINNED_ACCOUNTS_EDITOR_RESET,
|
||||
PINNED_ACCOUNTS_FETCH_REQUEST,
|
||||
PINNED_ACCOUNTS_FETCH_SUCCESS,
|
||||
PINNED_ACCOUNTS_FETCH_FAIL,
|
||||
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
|
||||
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
|
||||
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
|
||||
ACCOUNT_PIN_SUCCESS,
|
||||
ACCOUNT_UNPIN_SUCCESS,
|
||||
} from '../actions/accounts';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
accounts: ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
loaded: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
suggestions: ImmutableMap({
|
||||
value: '',
|
||||
items: ImmutableList(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default function listEditorReducer(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case PINNED_ACCOUNTS_EDITOR_RESET:
|
||||
return initialState;
|
||||
case PINNED_ACCOUNTS_FETCH_REQUEST:
|
||||
return state.setIn(['accounts', 'isLoading'], true);
|
||||
case PINNED_ACCOUNTS_FETCH_FAIL:
|
||||
return state.setIn(['accounts', 'isLoading'], false);
|
||||
case PINNED_ACCOUNTS_FETCH_SUCCESS:
|
||||
return state.update('accounts', accounts => accounts.withMutations(map => {
|
||||
map.set('isLoading', false);
|
||||
map.set('loaded', true);
|
||||
map.set('items', ImmutableList(action.accounts.map(item => item.id)));
|
||||
}));
|
||||
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE:
|
||||
return state.setIn(['suggestions', 'value'], action.value);
|
||||
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY:
|
||||
return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
|
||||
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR:
|
||||
return state.update('suggestions', suggestions => suggestions.withMutations(map => {
|
||||
map.set('items', ImmutableList());
|
||||
map.set('value', '');
|
||||
}));
|
||||
case ACCOUNT_PIN_SUCCESS:
|
||||
return state.updateIn(['accounts', 'items'], list => list.unshift(action.relationship.id));
|
||||
case ACCOUNT_UNPIN_SUCCESS:
|
||||
return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.relationship.id));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
|
@ -9,7 +9,7 @@ import {
|
|||
COMPOSE_REPLY,
|
||||
COMPOSE_DIRECT,
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
value: '',
|
||||
|
@ -39,7 +39,7 @@ export default function search(state = initialState, action) {
|
|||
return state.set('results', ImmutableMap({
|
||||
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
|
||||
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
||||
hashtags: ImmutableList(action.results.hashtags),
|
||||
hashtags: fromJS(action.results.hashtags),
|
||||
})).set('submitted', true);
|
||||
default:
|
||||
return state;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { me } from 'flavours/glitch/util/initial_state';
|
||||
|
||||
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
||||
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
|
||||
|
@ -77,7 +78,7 @@ export const makeGetStatus = () => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const regex = regexFromFilters(filters);
|
||||
const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
|
||||
let filtered = false;
|
||||
|
||||
if (statusReblog) {
|
||||
|
|
|
@ -255,11 +255,12 @@
|
|||
& > div {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
height: 100px;
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
overflow: hidden;
|
||||
|
||||
input {
|
||||
display: block;
|
||||
|
@ -298,6 +299,34 @@
|
|||
}
|
||||
}
|
||||
|
||||
.composer--upload_form--actions {
|
||||
background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
opacity: 0;
|
||||
transition: opacity .1s ease;
|
||||
|
||||
.icon-button {
|
||||
flex: 0 1 auto;
|
||||
color: $ui-secondary-color;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: lighten($ui-secondary-color, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.composer--upload_form--progress {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
|
@ -377,6 +406,12 @@
|
|||
background: $ui-highlight-color;
|
||||
transition: none;
|
||||
}
|
||||
&.top {
|
||||
& > .value {
|
||||
border-radius: 0 0 4px 4px;
|
||||
box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -763,3 +763,39 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.focal-point {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
&.dragging {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__reticle {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: url('~/images/reticle.png') no-repeat 0 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -90,16 +90,80 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-results__hashtag {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
color: $secondary-text-color;
|
||||
text-decoration: none;
|
||||
.trends {
|
||||
&__header {
|
||||
color: $dark-text-color;
|
||||
background: lighten($ui-base-color, 2%);
|
||||
border-bottom: 1px solid darken($ui-base-color, 4%);
|
||||
font-weight: 500;
|
||||
padding: 15px;
|
||||
font-size: 16px;
|
||||
cursor: default;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: lighten($secondary-text-color, 4%);
|
||||
text-decoration: underline;
|
||||
.fa {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1 1 auto;
|
||||
color: $dark-text-color;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $darker-text-color;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__current {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
font-size: 24px;
|
||||
line-height: 36px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: $secondary-text-color;
|
||||
}
|
||||
|
||||
&__sparkline {
|
||||
flex: 0 0 auto;
|
||||
width: 50px;
|
||||
|
||||
path {
|
||||
stroke: lighten($highlight-text-color, 6%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -417,15 +417,31 @@
|
|||
align-items: center;
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
|
||||
&__counter {
|
||||
display: inline-flex;
|
||||
margin-right: 11px;
|
||||
align-items: center;
|
||||
|
||||
.status__action-bar-button {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $action-button-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__action-bar-button {
|
||||
float: left;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.status__action-bar-dropdown {
|
||||
float: left;
|
||||
height: 23.15px;
|
||||
width: 23.15px;
|
||||
}
|
||||
|
|
|
@ -38,6 +38,10 @@ export function ListEditor () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'flavours/glitch/features/list_editor');
|
||||
}
|
||||
|
||||
export function PinnedAccountsEditor () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'flavours/glitch/features/pinned_accounts_editor');
|
||||
}
|
||||
|
||||
export function DirectTimeline() {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'flavours/glitch/features/direct_timeline');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
|
||||
export const shortNumberFormat = number => {
|
||||
if (number < 1000) {
|
||||
return <FormattedNumber value={number} />;
|
||||
} else {
|
||||
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>;
|
||||
}
|
||||
};
|
|
@ -18,7 +18,7 @@ pack:
|
|||
mailer:
|
||||
modal:
|
||||
public: public.js
|
||||
settings:
|
||||
settings: public.js
|
||||
share: share.js
|
||||
|
||||
# (OPTIONAL) The directory which contains localization files for
|
||||
|
|
|
@ -140,7 +140,7 @@ export function redraft(status) {
|
|||
};
|
||||
};
|
||||
|
||||
export function deleteStatus(id, withRedraft = false) {
|
||||
export function deleteStatus(id, router, withRedraft = false) {
|
||||
return (dispatch, getState) => {
|
||||
const status = getState().getIn(['statuses', id]);
|
||||
|
||||
|
@ -153,6 +153,10 @@ export function deleteStatus(id, withRedraft = false) {
|
|||
|
||||
if (withRedraft) {
|
||||
dispatch(redraft(status));
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
dispatch(deleteStatusFail(id, error));
|
||||
|
|
|
@ -55,7 +55,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
|
||||
if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
|
||||
params.since_id = timeline.getIn(['items', 0]);
|
||||
}
|
||||
|
||||
|
|
|
@ -137,7 +137,7 @@ class DropdownMenu extends React.PureComponent {
|
|||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
|
||||
<ul>
|
||||
|
|
|
@ -50,7 +50,7 @@ class Item extends React.PureComponent {
|
|||
handleClick = (e) => {
|
||||
const { index, onClick } = this.props;
|
||||
|
||||
if (e.button === 0) {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onClick(index);
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ export default class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (this.context.router && e.button === 0) {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${id}`);
|
||||
|
|
|
@ -32,6 +32,16 @@ const messages = defineMessages({
|
|||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
});
|
||||
|
||||
const obfuscatedCount = count => {
|
||||
if (count < 0) {
|
||||
return 0;
|
||||
} else if (count <= 1) {
|
||||
return count;
|
||||
} else {
|
||||
return '1+';
|
||||
}
|
||||
};
|
||||
|
||||
@injectIntl
|
||||
export default class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
|
@ -86,11 +96,11 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status);
|
||||
this.props.onDelete(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
||||
handleRedraftClick = () => {
|
||||
this.props.onDelete(this.props.status, true);
|
||||
this.props.onDelete(this.props.status, this.context.router.history, true);
|
||||
}
|
||||
|
||||
handlePinClick = () => {
|
||||
|
@ -194,7 +204,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
|
||||
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
|
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
{shareButton}
|
||||
|
|
|
@ -64,7 +64,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
|
||||
onMentionClick = (mention, e) => {
|
||||
if (this.context.router && e.button === 0) {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${mention.get('id')}`);
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (this.context.router && e.button === 0) {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/timelines/tag/${hashtag}`);
|
||||
}
|
||||
|
|
|
@ -93,14 +93,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
}));
|
||||
},
|
||||
|
||||
onDelete (status, withRedraft = false) {
|
||||
onDelete (status, history, withRedraft = false) {
|
||||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id'), withRedraft));
|
||||
dispatch(deleteStatus(status.get('id'), history, withRedraft));
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
|
||||
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
|
|
@ -147,17 +147,17 @@ export default class ActionBar extends React.PureComponent {
|
|||
|
||||
<div className='account__action-bar'>
|
||||
<div className='account__action-bar-links'>
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Toots' />
|
||||
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong>
|
||||
</Link>
|
||||
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
||||
<FormattedMessage id='account.follows' defaultMessage='Follows' />
|
||||
<strong>{shortNumberFormat(account.get('following_count'))}</strong>
|
||||
</Link>
|
||||
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
||||
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
||||
<strong>{shortNumberFormat(account.get('followers_count'))}</strong>
|
||||
</Link>
|
||||
|
|
|
@ -28,6 +28,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
|
|||
style: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
placement: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
@ -119,7 +120,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
|
|||
|
||||
render () {
|
||||
const { mounted } = this.state;
|
||||
const { style, items, value } = this.props;
|
||||
const { style, items, placement, value } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
|
@ -127,7 +128,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
|
|||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// react-overlays
|
||||
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
|
||||
<div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
|
||||
{items.map(item => (
|
||||
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
|
||||
<div className='privacy-dropdown__option__icon'>
|
||||
|
@ -226,7 +227,7 @@ export default class PrivacyDropdown extends React.PureComponent {
|
|||
const valueOption = this.options.find(item => item.value === value);
|
||||
|
||||
return (
|
||||
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
|
||||
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
|
||||
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
|
||||
<IconButton
|
||||
className='privacy-dropdown__value-icon'
|
||||
|
@ -247,6 +248,7 @@ export default class PrivacyDropdown extends React.PureComponent {
|
|||
value={value}
|
||||
onClose={this.handleClose}
|
||||
onChange={this.handleChange}
|
||||
placement={placement}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
|
|
|
@ -30,7 +30,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0) {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ export default class Upload extends ImmutablePureComponent {
|
|||
onUndo: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
onOpenFocalPoint: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -28,6 +29,17 @@ export default class Upload extends ImmutablePureComponent {
|
|||
dirtyDescription: null,
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
this.handleInputBlur();
|
||||
this.props.onSubmit();
|
||||
}
|
||||
|
||||
handleUndoClick = () => {
|
||||
this.props.onUndo(this.props.media.get('id'));
|
||||
}
|
||||
|
@ -93,6 +105,7 @@ export default class Upload extends ImmutablePureComponent {
|
|||
onFocus={this.handleInputFocus}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.handleInputBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@ import { connect } from 'react-redux';
|
|||
import Upload from '../components/upload';
|
||||
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { submitCompose } from '../../../actions/compose';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
|
@ -21,6 +22,10 @@ const mapDispatchToProps = dispatch => ({
|
|||
dispatch(openModal('FOCAL_POINT', { id }));
|
||||
},
|
||||
|
||||
onSubmit () {
|
||||
dispatch(submitCompose());
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Upload);
|
||||
|
|
|
@ -139,6 +139,7 @@ export default class GettingStarted extends ImmutablePureComponent {
|
|||
{multiColumn && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
|
||||
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
|
||||
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this instance' /></a> · </li>
|
||||
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
|
||||
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
|
||||
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
|
||||
<li><a href='https://github.com/tootsuite/documentation#documentation' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
|
||||
|
|
|
@ -65,11 +65,11 @@ export default class ActionBar extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status);
|
||||
this.props.onDelete(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
||||
handleRedraftClick = () => {
|
||||
this.props.onDelete(this.props.status, true);
|
||||
this.props.onDelete(this.props.status, this.context.router.history, true);
|
||||
}
|
||||
|
||||
handleDirectClick = () => {
|
||||
|
|
|
@ -26,7 +26,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0) {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
|
|
@ -174,16 +174,16 @@ export default class Status extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleDeleteClick = (status, withRedraft = false) => {
|
||||
handleDeleteClick = (status, history, withRedraft = false) => {
|
||||
const { dispatch, intl } = this.props;
|
||||
|
||||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id'), withRedraft));
|
||||
dispatch(deleteStatus(status.get('id'), history, withRedraft));
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
|
||||
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -355,7 +355,9 @@ export default class Status extends ImmutablePureComponent {
|
|||
if (status && ancestorsIds && ancestorsIds.size > 0) {
|
||||
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
|
||||
|
||||
element.scrollIntoView(true);
|
||||
window.requestAnimationFrame(() => {
|
||||
element.scrollIntoView(true);
|
||||
});
|
||||
this._scrolledIntoView = true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ export default class BoostModal extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0) {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.props.onClose();
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
|
|
|
@ -89,6 +89,7 @@ const keyMap = {
|
|||
goToProfile: 'g u',
|
||||
goToBlocked: 'g b',
|
||||
goToMuted: 'g m',
|
||||
goToRequests: 'g r',
|
||||
toggleHidden: 'x',
|
||||
};
|
||||
|
||||
|
@ -427,6 +428,10 @@ export default class UI extends React.PureComponent {
|
|||
this.context.router.history.push('/mutes');
|
||||
}
|
||||
|
||||
handleHotkeyGoToRequests = () => {
|
||||
this.context.router.history.push('/follow_requests');
|
||||
}
|
||||
|
||||
render () {
|
||||
const { draggingOver } = this.state;
|
||||
const { children, isComposing, location, dropdownMenuIsOpen } = this.props;
|
||||
|
@ -449,6 +454,7 @@ export default class UI extends React.PureComponent {
|
|||
goToProfile: this.handleHotkeyGoToProfile,
|
||||
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||
goToMuted: this.handleHotkeyGoToMuted,
|
||||
goToRequests: this.handleHotkeyGoToRequests,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -158,6 +158,9 @@ export default class Video extends React.PureComponent {
|
|||
this.setState({ dragging: true });
|
||||
this.video.pause();
|
||||
this.handleMouseMove(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleMouseUp = () => {
|
||||
|
@ -174,8 +177,10 @@ export default class Video extends React.PureComponent {
|
|||
const { x } = getPointerPosition(this.seek, e);
|
||||
const currentTime = Math.floor(this.video.duration * x);
|
||||
|
||||
this.video.currentTime = currentTime;
|
||||
this.setState({ currentTime });
|
||||
if (!isNaN(currentTime)) {
|
||||
this.video.currentTime = currentTime;
|
||||
this.setState({ currentTime });
|
||||
}
|
||||
}, 60);
|
||||
|
||||
togglePlay = () => {
|
||||
|
@ -281,6 +286,15 @@ export default class Video extends React.PureComponent {
|
|||
playerStyle.height = height;
|
||||
}
|
||||
|
||||
let preload;
|
||||
if (startTime || fullscreen || dragging) {
|
||||
preload = 'auto';
|
||||
} else if (detailed) {
|
||||
preload = 'metadata';
|
||||
} else {
|
||||
preload = 'none';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role='menuitem'
|
||||
|
@ -296,7 +310,7 @@ export default class Video extends React.PureComponent {
|
|||
ref={this.setVideoRef}
|
||||
src={src}
|
||||
poster={preview}
|
||||
preload={startTime ? 'auto' : 'none'}
|
||||
preload={preload}
|
||||
loop
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.",
|
||||
"account.domain_blocked": "النطاق مخفي",
|
||||
"account.edit_profile": "تعديل الملف الشخصي",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.endorse": "إبرازه على الملف الشخصي",
|
||||
"account.follow": "تابِع",
|
||||
"account.followers": "المتابعون",
|
||||
"account.follows": "يتبع",
|
||||
|
@ -27,7 +27,7 @@
|
|||
"account.show_reblogs": "عرض ترقيات @{name}",
|
||||
"account.unblock": "إلغاء الحظر عن @{name}",
|
||||
"account.unblock_domain": "فك حظر {domain}",
|
||||
"account.unendorse": "Don't feature on profile",
|
||||
"account.unendorse": "إزالة ترويجه مِن الملف الشخصي",
|
||||
"account.unfollow": "إلغاء المتابعة",
|
||||
"account.unmute": "إلغاء الكتم عن @{name}",
|
||||
"account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
|
||||
|
@ -90,7 +90,7 @@
|
|||
"confirmations.redraft.message": "هل أنت متأكد من أنك تريد حذف هذا المنشور و إعادة صياغته ؟ سوف تفقد جميع الردود و الترقيات و المفضلة المتصلة به.",
|
||||
"confirmations.unfollow.confirm": "إلغاء المتابعة",
|
||||
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
|
||||
"embed.instructions": "يمكنكم إدماج هذه الحالة على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
|
||||
"embed.instructions": "يمكنكم إدماج هذا المنشور على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
|
||||
"embed.preview": "هكذا ما سوف يبدو عليه :",
|
||||
"emoji_button.activity": "الأنشطة",
|
||||
"emoji_button.custom": "مخصص",
|
||||
|
@ -109,7 +109,7 @@
|
|||
"empty_column.community": "الخط الزمني المحلي فارغ. أكتب شيئا ما للعامة كبداية !",
|
||||
"empty_column.direct": "لم تتلق أية رسالة خاصة مباشِرة بعد. سوف يتم عرض الرسائل المباشرة هنا إن قمت بإرسال واحدة أو تلقيت البعض منها.",
|
||||
"empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
|
||||
"empty_column.home": "إنك لا تتبع بعد أي شخص إلى حد الآن. زر {public} أو استخدام حقل البحث لكي تبدأ على التعرف على مستخدمين آخرين.",
|
||||
"empty_column.home": "إنّ الخيط الزمني لصفحتك الرئيسية فارغ. قم بزيارة {public} أو استخدم حقل البحث لكي تكتشف مستخدمين آخرين.",
|
||||
"empty_column.home.public_timeline": "الخيط العام",
|
||||
"empty_column.list": "هذه القائمة فارغة مؤقتا و لكن سوف تمتلئ تدريجيا عندما يبدأ الأعضاء المُنتَمين إليها بنشر تبويقات.",
|
||||
"empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
|
||||
|
@ -162,6 +162,7 @@
|
|||
"missing_indicator.label": "تعذر العثور عليه",
|
||||
"missing_indicator.sublabel": "تعذر العثور على هذا المورد",
|
||||
"mute_modal.hide_notifications": "هل تود إخفاء الإخطارات القادمة من هذا المستخدم ؟",
|
||||
"navigation_bar.apps": "Mobile apps",
|
||||
"navigation_bar.blocks": "الحسابات المحجوبة",
|
||||
"navigation_bar.community_timeline": "الخيط العام المحلي",
|
||||
"navigation_bar.direct": "الرسائل المباشِرة",
|
||||
|
@ -181,7 +182,7 @@
|
|||
"navigation_bar.preferences": "التفضيلات",
|
||||
"navigation_bar.public_timeline": "الخيط العام الموحد",
|
||||
"navigation_bar.security": "الأمان",
|
||||
"notification.favourite": "{name} أعجب بمنشورك",
|
||||
"notification.favourite": "أُعجِب {name} بمنشورك",
|
||||
"notification.follow": "{name} يتابعك",
|
||||
"notification.mention": "{name} ذكرك",
|
||||
"notification.reblog": "{name} قام بترقية تبويقك",
|
||||
|
@ -272,7 +273,7 @@
|
|||
"status.pinned": "تبويق مثبَّت",
|
||||
"status.reblog": "رَقِّي",
|
||||
"status.reblog_private": "القيام بالترقية إلى الجمهور الأصلي",
|
||||
"status.reblogged_by": "{name} رقى",
|
||||
"status.reblogged_by": "رقّاه {name}",
|
||||
"status.redraft": "إزالة و إعادة الصياغة",
|
||||
"status.reply": "ردّ",
|
||||
"status.replyAll": "رُد على الخيط",
|
||||
|
|
|
@ -162,6 +162,7 @@
|
|||
"missing_indicator.label": "Not found",
|
||||
"missing_indicator.sublabel": "This resource could not be found",
|
||||
"mute_modal.hide_notifications": "Hide notifications from this user?",
|
||||
"navigation_bar.apps": "Mobile apps",
|
||||
"navigation_bar.blocks": "Blocked users",
|
||||
"navigation_bar.community_timeline": "Local timeline",
|
||||
"navigation_bar.direct": "Direct messages",
|
||||
|
|
|
@ -162,6 +162,7 @@
|
|||
"missing_indicator.label": "Not found",
|
||||
"missing_indicator.sublabel": "This resource could not be found",
|
||||
"mute_modal.hide_notifications": "Hide notifications from this user?",
|
||||
"navigation_bar.apps": "Mobile apps",
|
||||
"navigation_bar.blocks": "Blocked users",
|
||||
"navigation_bar.community_timeline": "Local timeline",
|
||||
"navigation_bar.direct": "Direct messages",
|
||||
|
|
|
@ -162,6 +162,7 @@
|
|||
"missing_indicator.label": "No trobat",
|
||||
"missing_indicator.sublabel": "Aquest recurs no pot ser trobat",
|
||||
"mute_modal.hide_notifications": "Amagar notificacions d'aquest usuari?",
|
||||
"navigation_bar.apps": "Mobile apps",
|
||||
"navigation_bar.blocks": "Usuaris bloquejats",
|
||||
"navigation_bar.community_timeline": "Línia de temps Local",
|
||||
"navigation_bar.direct": "Missatges directes",
|
||||
|
|
|
@ -162,6 +162,7 @@
|
|||
"missing_indicator.label": "Micca trovu",
|
||||
"missing_indicator.sublabel": "Ùn era micca pussivule di truvà sta risorsa",
|
||||
"mute_modal.hide_notifications": "Piattà nutificazione da st'utilizatore?",
|
||||
"navigation_bar.apps": "Mobile apps",
|
||||
"navigation_bar.blocks": "Utilizatori bluccati",
|
||||
"navigation_bar.community_timeline": "Linea pubblica lucale",
|
||||
"navigation_bar.direct": "Missaghji diretti",
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"account.disclaimer_full": "Níže uvedené informace nemusejí zcela odrážet profil uživatele.",
|
||||
"account.domain_blocked": "Doména skryta",
|
||||
"account.edit_profile": "Upravit profil",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.endorse": "Představit na profilu",
|
||||
"account.follow": "Sleduj",
|
||||
"account.followers": "Sledovatelé",
|
||||
"account.follows": "Sleduje",
|
||||
|
@ -27,7 +27,7 @@
|
|||
"account.show_reblogs": "Zobrazit boosty od uživatele @{name}",
|
||||
"account.unblock": "Odblokovat uživatele @{name}",
|
||||
"account.unblock_domain": "Odkrýt doménu {domain}",
|
||||
"account.unendorse": "Don't feature on profile",
|
||||
"account.unendorse": "Nepředstavit na profilu",
|
||||
"account.unfollow": "Přestat sledovat",
|
||||
"account.unmute": "Přestat ignorovat uživatele @{name}",
|
||||
"account.unmute_notifications": "Odtišit oznámení od uživatele @{name}",
|
||||
|
@ -162,6 +162,7 @@
|
|||
"missing_indicator.label": "Nenalezeno",
|
||||
"missing_indicator.sublabel": "Tento zdroj se nepodařilo najít",
|
||||
"mute_modal.hide_notifications": "Skrýt oznámení před tímto uživatelem?",
|
||||
"navigation_bar.apps": "Mobile apps",
|
||||
"navigation_bar.blocks": "Blokovaní uživatelé",
|
||||
"navigation_bar.community_timeline": "Místní časová osa",
|
||||
"navigation_bar.direct": "Přímé zprávy",
|
||||
|
@ -229,11 +230,11 @@
|
|||
"privacy.unlisted.short": "Nezobrazované",
|
||||
"regeneration_indicator.label": "Načítám…",
|
||||
"regeneration_indicator.sublabel": "Váš domovský proud se připravuje!",
|
||||
"relative_time.days": "{number}d",
|
||||
"relative_time.hours": "{number}h",
|
||||
"relative_time.days": "{number} d",
|
||||
"relative_time.hours": "{number} h",
|
||||
"relative_time.just_now": "teď",
|
||||
"relative_time.minutes": "{number}m",
|
||||
"relative_time.seconds": "{number}s",
|
||||
"relative_time.minutes": "{number} m",
|
||||
"relative_time.seconds": "{number} s",
|
||||
"reply_indicator.cancel": "Zrušit",
|
||||
"report.forward": "Přeposlat k {target}",
|
||||
"report.forward_hint": "Tento účet je z jiného serveru. Chcete na něj také poslat anonymizovanou kopii?",
|
||||
|
|
|
@ -7,27 +7,27 @@
|
|||
"account.disclaimer_full": "Nedenstående oplysninger reflekterer ikke nødvendigvis brugerens profil fuldstændigt.",
|
||||
"account.domain_blocked": "Domænet er blevet skjult",
|
||||
"account.edit_profile": "Rediger profil",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.endorse": "Fremhæv på profil",
|
||||
"account.follow": "Følg",
|
||||
"account.followers": "Følgere",
|
||||
"account.follows": "Følger",
|
||||
"account.follows_you": "Følger dig",
|
||||
"account.hide_reblogs": "Skjul fremhævelserne fra @{name}",
|
||||
"account.media": "Multimedier",
|
||||
"account.media": "Medie",
|
||||
"account.mention": "Nævn @{name}",
|
||||
"account.moved_to": "{name} er flyttet til:",
|
||||
"account.mute": "Dæmp @{name}",
|
||||
"account.mute_notifications": "Dæmp notifikationer fra @{name}",
|
||||
"account.muted": "Dæmpet",
|
||||
"account.posts": "Dyt",
|
||||
"account.posts_with_replies": "Toots og svar",
|
||||
"account.posts": "Trut",
|
||||
"account.posts_with_replies": "Trut samt svar",
|
||||
"account.report": "Rapporter @{name}",
|
||||
"account.requested": "Afventer godkendelse. Tryk for at annullere følgeanmodning",
|
||||
"account.share": "Del @{name}s profil",
|
||||
"account.show_reblogs": "Vis fremhævelserne fra @{name}",
|
||||
"account.unblock": "Fjern blokeringen af @{name}",
|
||||
"account.unblock_domain": "Skjul ikke længere {domain}",
|
||||
"account.unendorse": "Don't feature on profile",
|
||||
"account.unendorse": "Fremhæv ikke på profil",
|
||||
"account.unfollow": "Følg ikke længere",
|
||||
"account.unmute": "Fjern dæmpningen af @{name}",
|
||||
"account.unmute_notifications": "Fjern dæmpningen af notifikationer fra @{name}",
|
||||
|
@ -83,7 +83,7 @@
|
|||
"confirmations.delete_list.confirm": "Slet",
|
||||
"confirmations.delete_list.message": "Er du sikker på, du vil slette denne liste?",
|
||||
"confirmations.domain_block.confirm": "Skjul helt domæne",
|
||||
"confirmations.domain_block.message": "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.",
|
||||
"confirmations.domain_block.message": "Er du helt sikker på du vil blokere hele {domain} domænet? I de fleste tilfælde vil få specifikke blokeringer eller dæmpninger være nok og at fortrække. Du vil ikke se indhold fra det domæne hverken på offentlige tidslinjer eller i dine notifikationer. Dine følgere fra det domæne vil blive fjernet.",
|
||||
"confirmations.mute.confirm": "Dæmp",
|
||||
"confirmations.mute.message": "Er du sikker på, du vil dæmpe {name}?",
|
||||
"confirmations.redraft.confirm": "Slet & omskriv",
|
||||
|
@ -103,7 +103,7 @@
|
|||
"emoji_button.people": "Mennesker",
|
||||
"emoji_button.recent": "Oftest brugt",
|
||||
"emoji_button.search": "Søg...",
|
||||
"emoji_button.search_results": "Søgeresultat",
|
||||
"emoji_button.search_results": "Søgeresultater",
|
||||
"emoji_button.symbols": "Symboler",
|
||||
"emoji_button.travel": "Rejser & steder",
|
||||
"empty_column.community": "Den lokale tidslinje er tom. Skriv noget offentligt for at starte lavinen!",
|
||||
|
@ -139,7 +139,7 @@
|
|||
"keyboard_shortcuts.hotkey": "Hurtigtast",
|
||||
"keyboard_shortcuts.legend": "for at vise denne legende",
|
||||
"keyboard_shortcuts.mention": "for at nævne forfatteren",
|
||||
"keyboard_shortcuts.profile": "to open author's profile",
|
||||
"keyboard_shortcuts.profile": "til profil af åben forfatter",
|
||||
"keyboard_shortcuts.reply": "for at svare",
|
||||
"keyboard_shortcuts.search": "for at fokusere søgningen",
|
||||
"keyboard_shortcuts.toggle_hidden": "for at vise/skjule tekst bag CW",
|
||||
|
@ -162,6 +162,7 @@
|
|||
"missing_indicator.label": "Ikke fundet",
|
||||
"missing_indicator.sublabel": "Denne ressource kunne ikke blive fundet",
|
||||
"mute_modal.hide_notifications": "Skjul notifikationer fra denne bruger?",
|
||||
"navigation_bar.apps": "Mobile apps",
|
||||
"navigation_bar.blocks": "Blokerede brugere",
|
||||
"navigation_bar.community_timeline": "Lokal tidslinje",
|
||||
"navigation_bar.direct": "Direkte beskeder",
|
||||
|
@ -215,8 +216,8 @@
|
|||
"onboarding.page_six.read_guidelines": "Læs venligst {domain}s {guidelines}!",
|
||||
"onboarding.page_six.various_app": "apps til mobilen",
|
||||
"onboarding.page_three.profile": "Rediger din profil for at ændre profilbillede, beskrivelse og visningsnavn. Der vil du også finde andre indstillinger.",
|
||||
"onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
|
||||
"onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
|
||||
"onboarding.page_three.search": "Brug søgefeltdet for at finde folk og at kigge på hashtags, så som {illustration} and {introductions}. For at finde en person der ikke er på denne instans, brug deres fulde brugernavn.",
|
||||
"onboarding.page_two.compose": "Skriv opslag fra skrive kolonnen. Du kan uploade billeder, ændre privatlivsindstillinger, og tilføje indholds advarsler med ikoner forneden.",
|
||||
"onboarding.skip": "Spring over",
|
||||
"privacy.change": "Ændre status privatliv",
|
||||
"privacy.direct.long": "Post til kun de nævnte brugere",
|
||||
|
@ -243,7 +244,7 @@
|
|||
"report.target": "Anmelder {target}",
|
||||
"search.placeholder": "Søg",
|
||||
"search_popout.search_format": "Avanceret søgeformat",
|
||||
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
|
||||
"search_popout.tips.full_text": "Simpel tekst returnerer statusser du har skrevet, favoriseret, fremhævet, eller er blevet nævnt i, lige så vel som matchende brugernavne, visningsnavne, og hashtags.",
|
||||
"search_popout.tips.hashtag": "emnetag",
|
||||
"search_popout.tips.status": "status",
|
||||
"search_popout.tips.text": "Simpelt tekst returnerer passende visningsnavne, brugernavne og hashtags",
|
||||
|
@ -275,7 +276,7 @@
|
|||
"status.reblogged_by": "{name} fremhævede",
|
||||
"status.redraft": "Slet og omskriv",
|
||||
"status.reply": "Svar",
|
||||
"status.replyAll": "Svar tråd",
|
||||
"status.replyAll": "Svar samtale",
|
||||
"status.report": "Anmeld @{name}",
|
||||
"status.sensitive_toggle": "Tryk for at se",
|
||||
"status.sensitive_warning": "Følsomt indhold",
|
||||
|
@ -291,11 +292,11 @@
|
|||
"tabs_bar.local_timeline": "Lokal",
|
||||
"tabs_bar.notifications": "Notifikationer",
|
||||
"tabs_bar.search": "Søg",
|
||||
"trends.count_by_accounts": "{count} {rawCount, flere, en {person} flere {people}} snakker",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} snakker",
|
||||
"ui.beforeunload": "Din kladde vil gå tabt hvis du forlader Mastodon.",
|
||||
"upload_area.title": "Træk og slip for at uploade",
|
||||
"upload_button.label": "Tilføj multimedier",
|
||||
"upload_form.description": "Beskrivelse for de svagtseende",
|
||||
"upload_button.label": "Tilføj medie",
|
||||
"upload_form.description": "Beskriv for de svagtseende",
|
||||
"upload_form.focus": "Beskær",
|
||||
"upload_form.undo": "Slet",
|
||||
"upload_progress.label": "Uploader...",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue