commit
151b1aef06
@ -3,7 +3,7 @@ version: 2
|
||||
aliases:
|
||||
- &defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.6.0-stretch-node
|
||||
- image: circleci/ruby:2.6-stretch-node
|
||||
environment: &ruby_environment
|
||||
BUNDLE_APP_CONFIG: ./.bundle/
|
||||
DB_HOST: localhost
|
||||
@ -105,14 +105,14 @@ jobs:
|
||||
install-ruby2.5:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.5.3-stretch-node
|
||||
- image: circleci/ruby:2.5-stretch-node
|
||||
environment: *ruby_environment
|
||||
<<: *install_ruby_dependencies
|
||||
|
||||
install-ruby2.4:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.4.5-stretch-node
|
||||
- image: circleci/ruby:2.4-stretch-node
|
||||
environment: *ruby_environment
|
||||
<<: *install_ruby_dependencies
|
||||
|
||||
@ -131,40 +131,40 @@ jobs:
|
||||
test-ruby2.6:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.6.0-stretch-node
|
||||
- image: circleci/ruby:2.6-stretch-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:10.6-alpine
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
- image: circleci/redis:5.0.3-alpine3.8
|
||||
- image: circleci/redis:5-alpine
|
||||
<<: *test_steps
|
||||
|
||||
test-ruby2.5:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.5.3-stretch-node
|
||||
- image: circleci/ruby:2.5-stretch-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:10.6-alpine
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
- image: circleci/redis:4.0.12-alpine
|
||||
- image: circleci/redis:5-alpine
|
||||
<<: *test_steps
|
||||
|
||||
test-ruby2.4:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/ruby:2.4.5-stretch-node
|
||||
- image: circleci/ruby:2.4-stretch-node
|
||||
environment: *ruby_environment
|
||||
- image: circleci/postgres:10.6-alpine
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
- image: circleci/redis:4.0.12-alpine
|
||||
- image: circleci/redis:5-alpine
|
||||
<<: *test_steps
|
||||
|
||||
test-webui:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/node:8.15.0-stretch
|
||||
- image: circleci/node:12.9-stretch
|
||||
steps:
|
||||
- *attach_workspace
|
||||
- run: ./bin/retry yarn test:jest
|
||||
|
@ -4,7 +4,7 @@ FROM ubuntu:18.04 as build-dep
|
||||
SHELL ["bash", "-c"]
|
||||
|
||||
# Install Node
|
||||
ENV NODE_VER="8.15.0"
|
||||
ENV NODE_VER="12.9.1"
|
||||
RUN echo "Etc/UTC" > /etc/localtime && \
|
||||
apt update && \
|
||||
apt -y install wget make gcc g++ python && \
|
||||
@ -17,7 +17,7 @@ RUN echo "Etc/UTC" > /etc/localtime && \
|
||||
make install
|
||||
|
||||
# Install jemalloc
|
||||
ENV JE_VER="5.1.0"
|
||||
ENV JE_VER="5.2.1"
|
||||
RUN apt update && \
|
||||
apt -y install autoconf && \
|
||||
cd ~ && \
|
||||
@ -30,7 +30,7 @@ RUN apt update && \
|
||||
make install_bin install_include install_lib
|
||||
|
||||
# Install ruby
|
||||
ENV RUBY_VER="2.6.1"
|
||||
ENV RUBY_VER="2.6.4"
|
||||
ENV CPPFLAGS="-I/opt/jemalloc/include"
|
||||
ENV LDFLAGS="-L/opt/jemalloc/lib/"
|
||||
RUN apt update && \
|
||||
|
15
Gemfile
15
Gemfile
@ -12,7 +12,7 @@ gem 'thor', '~> 0.20'
|
||||
gem 'hamlit-rails', '~> 0.2'
|
||||
gem 'pg', '~> 1.1'
|
||||
gem 'makara', '~> 0.4'
|
||||
gem 'pghero', '~> 2.2'
|
||||
gem 'pghero', '~> 2.3'
|
||||
gem 'dotenv-rails', '~> 2.7'
|
||||
|
||||
gem 'aws-sdk-s3', '~> 1.46', require: false
|
||||
@ -31,7 +31,7 @@ gem 'charlock_holmes', '~> 0.7.6'
|
||||
gem 'iso-639'
|
||||
gem 'chewy', '~> 5.0'
|
||||
gem 'cld3', '~> 3.2.4'
|
||||
gem 'devise', '~> 4.6'
|
||||
gem 'devise', '~> 4.7'
|
||||
gem 'devise-two-factor', '~> 3.1'
|
||||
|
||||
group :pam_authentication, optional: true do
|
||||
@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
|
||||
gem 'omniauth-saml', '~> 1.10'
|
||||
gem 'omniauth', '~> 1.9'
|
||||
|
||||
gem 'discard', '~> 1.1'
|
||||
gem 'doorkeeper', '~> 5.1'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'fastimage'
|
||||
@ -61,12 +62,12 @@ gem 'mime-types', '~> 3.2', require: 'mime/types/columnar'
|
||||
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
|
||||
gem 'nokogiri', '~> 1.10'
|
||||
gem 'nsa', '~> 0.2'
|
||||
gem 'oj', '~> 3.8'
|
||||
gem 'oj', '~> 3.9'
|
||||
gem 'ostatus2', '~> 2.0'
|
||||
gem 'ox', '~> 2.11'
|
||||
gem 'parslet'
|
||||
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
|
||||
gem 'pundit', '~> 2.0'
|
||||
gem 'pundit', '~> 2.1'
|
||||
gem 'premailer-rails'
|
||||
gem 'rack-attack', '~> 6.1'
|
||||
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
|
||||
@ -80,7 +81,7 @@ gem 'sidekiq', '~> 5.2'
|
||||
gem 'sidekiq-scheduler', '~> 3.0'
|
||||
gem 'sidekiq-unique-jobs', '~> 6.0'
|
||||
gem 'sidekiq-bulk', '~>0.2.0'
|
||||
gem 'simple-navigation', '~> 4.0'
|
||||
gem 'simple-navigation', '~> 4.1'
|
||||
gem 'simple_form', '~> 4.1'
|
||||
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
|
||||
gem 'stoplight', '~> 2.1.3'
|
||||
@ -92,7 +93,7 @@ gem 'tzinfo-data', '~> 1.2019'
|
||||
gem 'webpacker', '~> 4.0'
|
||||
gem 'webpush'
|
||||
|
||||
gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: '345b7a5733308af827e8491d284dbafa9128d7a2'
|
||||
gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d'
|
||||
gem 'json-ld-preloaded', '~> 3.0'
|
||||
gem 'rdf-normalize', '~> 0.3'
|
||||
|
||||
@ -133,7 +134,7 @@ group :development do
|
||||
gem 'letter_opener_web', '~> 1.3'
|
||||
gem 'memory_profiler'
|
||||
gem 'rubocop', '~> 0.74', require: false
|
||||
gem 'rubocop-rails', '~> 2.2', require: false
|
||||
gem 'rubocop-rails', '~> 2.3', require: false
|
||||
gem 'brakeman', '~> 4.6', require: false
|
||||
gem 'bundler-audit', '~> 0.6', require: false
|
||||
|
||||
|
39
Gemfile.lock
39
Gemfile.lock
@ -7,8 +7,8 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/ruby-rdf/json-ld.git
|
||||
revision: 345b7a5733308af827e8491d284dbafa9128d7a2
|
||||
ref: 345b7a5733308af827e8491d284dbafa9128d7a2
|
||||
revision: e742697a0906e74e8bb777ef98137bc3955d981d
|
||||
ref: e742697a0906e74e8bb777ef98137bc3955d981d
|
||||
specs:
|
||||
json-ld (3.0.2)
|
||||
htmlentities (~> 4.3)
|
||||
@ -127,7 +127,7 @@ GEM
|
||||
brakeman (4.6.1)
|
||||
browser (2.6.1)
|
||||
builder (3.2.3)
|
||||
bullet (6.0.1)
|
||||
bullet (6.0.2)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundler-audit (0.6.1)
|
||||
@ -188,10 +188,10 @@ GEM
|
||||
rack (>= 1)
|
||||
rake (> 10, < 13)
|
||||
thor (~> 0.19)
|
||||
devise (4.6.2)
|
||||
devise (4.7.0)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0, < 6.0)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (3.1.0)
|
||||
@ -204,6 +204,8 @@ GEM
|
||||
devise (>= 4.0.0)
|
||||
rpam2 (~> 4.0)
|
||||
diff-lcs (1.3)
|
||||
discard (1.1.0)
|
||||
activerecord (>= 4.2, < 7)
|
||||
docile (1.3.2)
|
||||
domain_name (0.5.20180417)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
@ -385,7 +387,7 @@ GEM
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sidekiq (>= 3.5)
|
||||
statsd-ruby (~> 1.4, >= 1.4.0)
|
||||
oj (3.8.1)
|
||||
oj (3.9.0)
|
||||
omniauth (1.9.0)
|
||||
hashie (>= 3.4.6, < 3.7.0)
|
||||
rack (>= 1.6.2, < 3)
|
||||
@ -421,9 +423,9 @@ GEM
|
||||
equatable (~> 0.5.0)
|
||||
tty-color (~> 0.4.0)
|
||||
pg (1.1.4)
|
||||
pghero (2.2.1)
|
||||
activerecord
|
||||
pkg-config (1.3.7)
|
||||
pghero (2.3.0)
|
||||
activerecord (>= 5)
|
||||
pkg-config (1.3.8)
|
||||
premailer (1.11.1)
|
||||
addressable
|
||||
css_parser (>= 1.6.0)
|
||||
@ -443,7 +445,7 @@ GEM
|
||||
public_suffix (3.1.1)
|
||||
puma (4.1.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.0.1)
|
||||
pundit (2.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.1.6)
|
||||
rack (2.0.7)
|
||||
@ -553,7 +555,7 @@ GEM
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 1.7)
|
||||
rubocop-rails (2.2.1)
|
||||
rubocop-rails (2.3.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 0.72.0)
|
||||
ruby-progressbar (1.10.1)
|
||||
@ -582,7 +584,7 @@ GEM
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
sidekiq (>= 4.0, < 7.0)
|
||||
thor (~> 0)
|
||||
simple-navigation (4.0.5)
|
||||
simple-navigation (4.1.0)
|
||||
activesupport (>= 2.3.2)
|
||||
simple_form (4.1.0)
|
||||
actionpack (>= 5.0)
|
||||
@ -690,9 +692,10 @@ DEPENDENCIES
|
||||
concurrent-ruby
|
||||
connection_pool
|
||||
derailed_benchmarks
|
||||
devise (~> 4.6)
|
||||
devise (~> 4.7)
|
||||
devise-two-factor (~> 3.1)
|
||||
devise_pam_authenticatable2 (~> 9.2)
|
||||
discard (~> 1.1)
|
||||
doorkeeper (~> 5.1)
|
||||
dotenv-rails (~> 2.7)
|
||||
fabrication (~> 2.20)
|
||||
@ -729,7 +732,7 @@ DEPENDENCIES
|
||||
nilsimsa!
|
||||
nokogiri (~> 1.10)
|
||||
nsa (~> 0.2)
|
||||
oj (~> 3.8)
|
||||
oj (~> 3.9)
|
||||
omniauth (~> 1.9)
|
||||
omniauth-cas (~> 1.1)
|
||||
omniauth-saml (~> 1.10)
|
||||
@ -740,7 +743,7 @@ DEPENDENCIES
|
||||
parallel_tests (~> 2.29)
|
||||
parslet
|
||||
pg (~> 1.1)
|
||||
pghero (~> 2.2)
|
||||
pghero (~> 2.3)
|
||||
pkg-config (~> 1.3)
|
||||
posix-spawn!
|
||||
premailer-rails
|
||||
@ -748,7 +751,7 @@ DEPENDENCIES
|
||||
pry-byebug (~> 3.7)
|
||||
pry-rails (~> 0.3)
|
||||
puma (~> 4.1)
|
||||
pundit (~> 2.0)
|
||||
pundit (~> 2.1)
|
||||
rack-attack (~> 6.1)
|
||||
rack-cors (~> 1.0)
|
||||
rails (~> 5.2.3)
|
||||
@ -764,13 +767,13 @@ DEPENDENCIES
|
||||
rspec-rails (~> 3.8)
|
||||
rspec-sidekiq (~> 3.0)
|
||||
rubocop (~> 0.74)
|
||||
rubocop-rails (~> 2.2)
|
||||
rubocop-rails (~> 2.3)
|
||||
sanitize (~> 5.0)
|
||||
sidekiq (~> 5.2)
|
||||
sidekiq-bulk (~> 0.2.0)
|
||||
sidekiq-scheduler (~> 3.0)
|
||||
sidekiq-unique-jobs (~> 6.0)
|
||||
simple-navigation (~> 4.0)
|
||||
simple-navigation (~> 4.1)
|
||||
simple_form (~> 4.1)
|
||||
simplecov (~> 0.17)
|
||||
sprockets-rails (~> 3.2)
|
||||
|
@ -5,7 +5,7 @@ module Admin
|
||||
before_action :set_account
|
||||
|
||||
def new
|
||||
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true)
|
||||
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true)
|
||||
@warning_presets = AccountWarningPreset.all
|
||||
end
|
||||
|
||||
@ -30,7 +30,7 @@ module Admin
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification)
|
||||
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -37,7 +37,8 @@ module Admin
|
||||
|
||||
def set_usage_by_domain
|
||||
@usage_by_domain = @tag.statuses
|
||||
.where(visibility: :public)
|
||||
.with_public_visibility
|
||||
.excluding_silenced_accounts
|
||||
.where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day)))
|
||||
.joins(:account)
|
||||
.group('accounts.domain')
|
||||
|
@ -36,6 +36,14 @@ class Api::BaseController < ApplicationController
|
||||
render json: { error: 'This action is not allowed' }, status: 403
|
||||
end
|
||||
|
||||
rescue_from Mastodon::RaceConditionError do
|
||||
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
|
||||
end
|
||||
|
||||
rescue_from ActionController::ParameterMissing do |e|
|
||||
render json: { error: e.to_s }, status: 400
|
||||
end
|
||||
|
||||
def doorkeeper_unauthorized_render_options(error: nil)
|
||||
{ json: { error: (error.try(:description) || 'Not authorized') } }
|
||||
end
|
||||
|
@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
|
||||
|
||||
def account_statuses
|
||||
statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
|
||||
statuses = statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||
|
||||
statuses.merge!(only_media_scope) if truthy_param?(:only_media)
|
||||
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
|
||||
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
|
||||
statuses.merge!(hashtag_scope) if params[:tagged].present?
|
||||
|
||||
statuses
|
||||
statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||
end
|
||||
|
||||
def permitted_account_statuses
|
||||
|
30
app/controllers/api/v1/directories_controller.rb
Normal file
30
app/controllers/api/v1/directories_controller.rb
Normal file
@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::DirectoriesController < Api::BaseController
|
||||
before_action :require_enabled!
|
||||
before_action :set_accounts
|
||||
|
||||
def show
|
||||
render json: @accounts, each_serializer: REST::AccountSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_enabled!
|
||||
return not_found unless Setting.profile_directory
|
||||
end
|
||||
|
||||
def set_accounts
|
||||
@accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT))
|
||||
end
|
||||
|
||||
def accounts_scope
|
||||
Account.discoverable.tap do |scope|
|
||||
scope.merge!(Account.local) if truthy_param?(:local)
|
||||
scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active'
|
||||
scope.merge!(Account.order(id: :desc)) if params[:order] == 'new'
|
||||
scope.merge!(Account.not_excluded_by_account(current_account)) if current_account
|
||||
scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local)
|
||||
end
|
||||
end
|
||||
end
|
@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController
|
||||
private
|
||||
|
||||
def reported_status_ids
|
||||
reported_account.statuses.find(status_ids).pluck(:id)
|
||||
reported_account.statuses.with_discarded.find(status_ids).pluck(:id)
|
||||
end
|
||||
|
||||
def status_ids
|
||||
|
@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
||||
@reblogs_map = { @status.id => false }
|
||||
|
||||
authorize status_for_destroy, :unreblog?
|
||||
status_for_destroy.discard
|
||||
RemovalWorker.perform_async(status_for_destroy.id)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
|
||||
@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
||||
end
|
||||
|
||||
def status_for_destroy
|
||||
current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
|
||||
@status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
|
||||
end
|
||||
|
||||
def reblog_params
|
||||
|
@ -53,7 +53,8 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
@status = Status.where(account_id: current_user.account).find(params[:id])
|
||||
authorize @status, :destroy?
|
||||
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
@status.discard
|
||||
RemovalWorker.perform_async(@status.id, redraft: true)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
||||
end
|
||||
|
@ -21,11 +21,13 @@ class ApplicationController < ActionController::Base
|
||||
helper_method :whitelist_mode?
|
||||
|
||||
rescue_from ActionController::RoutingError, with: :not_found
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
|
||||
rescue_from ActionController::UnknownFormat, with: :not_acceptable
|
||||
rescue_from ActionController::ParameterMissing, with: :bad_request
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||
rescue_from Mastodon::NotPermittedError, with: :forbidden
|
||||
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
|
||||
rescue_from Mastodon::RaceConditionError, with: :service_unavailable
|
||||
|
||||
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
|
||||
before_action :require_functional!, if: :user_signed_in?
|
||||
@ -96,10 +98,18 @@ class ApplicationController < ActionController::Base
|
||||
respond_with_error(406)
|
||||
end
|
||||
|
||||
def bad_request
|
||||
respond_with_error(400)
|
||||
end
|
||||
|
||||
def internal_server_error
|
||||
respond_with_error(500)
|
||||
end
|
||||
|
||||
def service_unavailable
|
||||
respond_with_error(503)
|
||||
end
|
||||
|
||||
def single_user_mode?
|
||||
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
|
||||
end
|
||||
|
@ -7,7 +7,6 @@ class DirectoriesController < ApplicationController
|
||||
before_action :require_enabled!
|
||||
before_action :set_instance_presenter
|
||||
before_action :set_tag, only: :show
|
||||
before_action :set_tags
|
||||
before_action :set_accounts
|
||||
|
||||
def index
|
||||
@ -28,13 +27,10 @@ class DirectoriesController < ApplicationController
|
||||
@tag = Tag.discoverable.find_normalized!(params[:id])
|
||||
end
|
||||
|
||||
def set_tags
|
||||
@tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? }
|
||||
end
|
||||
|
||||
def set_accounts
|
||||
@accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query|
|
||||
@accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query|
|
||||
query.merge!(Account.tagged_with(@tag.id)) if @tag
|
||||
query.merge!(Account.not_excluded_by_account(current_account)) if current_account
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -29,7 +29,7 @@ class RemoteFollowController < ApplicationController
|
||||
end
|
||||
|
||||
def session_params
|
||||
{ acct: session[:remote_follow] }
|
||||
{ acct: session[:remote_follow] || current_account&.username }
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
|
@ -32,7 +32,7 @@ class RemoteInteractionController < ApplicationController
|
||||
end
|
||||
|
||||
def session_params
|
||||
{ acct: session[:remote_follow] }
|
||||
{ acct: session[:remote_follow] || current_account&.username }
|
||||
end
|
||||
|
||||
def set_status
|
||||
|
@ -11,7 +11,7 @@ module WellKnown
|
||||
|
||||
expires_in 3.days, public: true
|
||||
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
rescue ActiveRecord::RecordNotFound, ActionController::ParameterMissing
|
||||
head 404
|
||||
end
|
||||
|
||||
|
@ -34,6 +34,26 @@ module StatusesHelper
|
||||
end
|
||||
end
|
||||
|
||||
def minimal_account_action_button(account)
|
||||
if user_signed_in?
|
||||
return if account.id == current_user.account_id
|
||||
|
||||
if current_account.following?(account) || current_account.requested?(account)
|
||||
link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do
|
||||
fa_icon('user-times fw')
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do
|
||||
fa_icon('user-plus fw')
|
||||
end
|
||||
end
|
||||
elsif !(account.memorial? || account.moved?)
|
||||
link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do
|
||||
fa_icon('user-plus fw')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def svg_logo
|
||||
content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976')
|
||||
end
|
||||
|
@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
|
||||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
|
||||
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
@ -23,23 +25,29 @@ export function clearAlert() {
|
||||
};
|
||||
};
|
||||
|
||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
|
||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
|
||||
return {
|
||||
type: ALERT_SHOW,
|
||||
title,
|
||||
message,
|
||||
message_values,
|
||||
};
|
||||
};
|
||||
|
||||
export function showAlertForError(error) {
|
||||
if (error.response) {
|
||||
const { data, status, statusText } = error.response;
|
||||
const { data, status, statusText, headers } = error.response;
|
||||
|
||||
if (status === 404 || status === 410) {
|
||||
// Skip these errors as they are reflected in the UI
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||
const reset_date = new Date(headers['x-ratelimit-reset']);
|
||||
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
|
||||
}
|
||||
|
||||
let message = statusText;
|
||||
let title = `${status}`;
|
||||
|
||||
|
@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
|
||||
cancelFetchComposeSuggestionsTags();
|
||||
}
|
||||
|
||||
dispatch(updateSuggestionTags(token));
|
||||
|
||||
api(getState).get('/api/v2/search', {
|
||||
cancelToken: new CancelToken(cancel => {
|
||||
cancelFetchComposeSuggestionsTags = cancel;
|
||||
|
61
app/javascript/mastodon/actions/directory.js
Normal file
61
app/javascript/mastodon/actions/directory.js
Normal file
@ -0,0 +1,61 @@
|
||||
import api from '../api';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
import { fetchRelationships } from './accounts';
|
||||
|
||||
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
|
||||
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
|
||||
export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL';
|
||||
|
||||
export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
|
||||
export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
|
||||
export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL';
|
||||
|
||||
export const fetchDirectory = params => (dispatch, getState) => {
|
||||
dispatch(fetchDirectoryRequest());
|
||||
|
||||
api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchDirectorySuccess(data));
|
||||
dispatch(fetchRelationships(data.map(x => x.id)));
|
||||
}).catch(error => dispatch(fetchDirectoryFail(error)));
|
||||
};
|
||||
|
||||
export const fetchDirectoryRequest = () => ({
|
||||
type: DIRECTORY_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
export const fetchDirectorySuccess = accounts => ({
|
||||
type: DIRECTORY_FETCH_SUCCESS,
|
||||
accounts,
|
||||
});
|
||||
|
||||
export const fetchDirectoryFail = error => ({
|
||||
type: DIRECTORY_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const expandDirectory = params => (dispatch, getState) => {
|
||||
dispatch(expandDirectoryRequest());
|
||||
|
||||
const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
|
||||
|
||||
api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(expandDirectorySuccess(data));
|
||||
dispatch(fetchRelationships(data.map(x => x.id)));
|
||||
}).catch(error => dispatch(expandDirectoryFail(error)));
|
||||
};
|
||||
|
||||
export const expandDirectoryRequest = () => ({
|
||||
type: DIRECTORY_EXPAND_REQUEST,
|
||||
});
|
||||
|
||||
export const expandDirectorySuccess = accounts => ({
|
||||
type: DIRECTORY_EXPAND_SUCCESS,
|
||||
accounts,
|
||||
});
|
||||
|
||||
export const expandDirectoryFail = error => ({
|
||||
type: DIRECTORY_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent {
|
||||
tag: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
url: PropTypes.string,
|
||||
history: PropTypes.array.isRequired,
|
||||
history: PropTypes.array,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { tag } = this.props;
|
||||
const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
||||
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
||||
|
||||
return (
|
||||
<div className='autosuggest-hashtag'>
|
||||
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
||||
<div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>
|
||||
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent {
|
||||
if (multiColumn) {
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, document.getElementById('tabs-bar__portal'));
|
||||
// The portal container and the component may be rendered to the DOM in
|
||||
// the same React render pass, so the container might not be available at
|
||||
// the time `render()` is called.
|
||||
const container = document.getElementById('tabs-bar__portal');
|
||||
if (container === null) {
|
||||
// The container wasn't available, force a re-render so that the
|
||||
// component can eventually be inserted in the container and not scroll
|
||||
// with the rest of the area.
|
||||
this.forceUpdate();
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent {
|
||||
if (multiColumn || placeholder) {
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, document.getElementById('tabs-bar__portal'));
|
||||
// The portal container and the component may be rendered to the DOM in
|
||||
// the same React render pass, so the container might not be available at
|
||||
// the time `render()` is called.
|
||||
const container = document.getElementById('tabs-bar__portal');
|
||||
if (container === null) {
|
||||
// The container wasn't available, force a re-render so that the
|
||||
// component can eventually be inserted in the container and not scroll
|
||||
// with the rest of the area.
|
||||
this.forceUpdate();
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -316,14 +316,21 @@ class MediaGallery extends React.PureComponent {
|
||||
}
|
||||
|
||||
const size = media.take(4).size;
|
||||
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
if (uncached) {
|
||||
spoilerButton = (
|
||||
<button type='button' disabled className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
|
||||
</button>
|
||||
);
|
||||
} else if (visible) {
|
||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||
} else {
|
||||
spoilerButton = (
|
||||
@ -335,7 +342,7 @@ class MediaGallery extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
|
||||
|
35
app/javascript/mastodon/components/radio_button.js
Normal file
35
app/javascript/mastodon/components/radio_button.js
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class RadioButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
checked: PropTypes.bool,
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { name, value, checked, onChange, label } = this.props;
|
||||
|
||||
return (
|
||||
<label className='radio-button'>
|
||||
<input
|
||||
name={name}
|
||||
type='radio'
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span className={classNames('radio-button__input', { checked })} />
|
||||
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
|
||||
import Card from '../features/status/components/card';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
renderLoadingMediaGallery () {
|
||||
return <div className='media_gallery' style={{ height: '110px' }} />;
|
||||
return <div className='media-gallery' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingVideoPlayer () {
|
||||
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
||||
return <div className='video-player' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingAudioPlayer () {
|
||||
return <div className='audio-player' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
handleOpenVideo = (media, startTime) => {
|
||||
@ -278,12 +282,27 @@ class Status extends ImmutablePureComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handlers = this.props.muted ? {} : {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
};
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<div ref={this.handleRef}>
|
||||
<HotKeys handlers={handlers}>
|
||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex='0'>
|
||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
||||
{status.get('content')}
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
@ -333,7 +352,23 @@ class Status extends ImmutablePureComponent {
|
||||
media={status.get('media_attachments')}
|
||||
/>
|
||||
);
|
||||
} else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
peaks={[0]}
|
||||
height={70}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
@ -394,19 +429,6 @@ class Status extends ImmutablePureComponent {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const handlers = this.props.muted ? {} : {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
|
@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
);
|
||||
} else if (this.props.onClick) {
|
||||
const output = [
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
|
||||
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||
|
||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
|
@ -8,6 +8,7 @@ import Video from '../features/video';
|
||||
import Card from '../features/status/components/card';
|
||||
import Poll from 'mastodon/components/poll';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import ModalRoot from '../components/modal_root';
|
||||
import { getScrollbarWidth } from '../features/ui/components/modal_root';
|
||||
import MediaModal from '../features/ui/components/media_modal';
|
||||
@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag };
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||
|
||||
export default class MediaContainer extends PureComponent {
|
||||
|
||||
|
@ -96,6 +96,12 @@ export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
if (attachment.get('type') === 'unknown') {
|
||||
// Skip
|
||||
} else if (attachment.get('type') === 'audio') {
|
||||
thumbnail = (
|
||||
<span className='account-gallery__item__icons'>
|
||||
<Icon id='music' />
|
||||
</span>
|
||||
);
|
||||
} else if (attachment.get('type') === 'image') {
|
||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||
|
@ -100,7 +100,7 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
handleOpenMedia = attachment => {
|
||||
if (attachment.get('type') === 'video') {
|
||||
if (['video', 'audio'].includes(attachment.get('type'))) {
|
||||
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
||||
} else {
|
||||
const media = attachment.getIn(['status', 'media_attachments']);
|
||||
|
226
app/javascript/mastodon/features/audio/index.js
Normal file
226
app/javascript/mastodon/features/audio/index.js
Normal file
@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { formatTime } from 'mastodon/features/video';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Audio extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
duration: PropTypes.number,
|
||||
peaks: PropTypes.arrayOf(PropTypes.number),
|
||||
height: PropTypes.number,
|
||||
preload: PropTypes.bool,
|
||||
editable: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
currentTime: 0,
|
||||
duration: null,
|
||||
paused: true,
|
||||
muted: false,
|
||||
volume: 0.5,
|
||||
};
|
||||
|
||||
// hard coded in components.scss
|
||||
// any way to get ::before values programatically?
|
||||
|
||||
volWidth = 50;
|
||||
|
||||
volOffset = 70;
|
||||
|
||||
volHandleOffset = v => {
|
||||
const offset = v * this.volWidth + this.volOffset;
|
||||
return (offset > 110) ? 110 : offset;
|
||||
}
|
||||
|
||||
setVolumeRef = c => {
|
||||
this.volume = c;
|
||||
}
|
||||
|
||||
setWaveformRef = c => {
|
||||
this.waveform = c;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (this.waveform) {
|
||||
this._updateWaveform();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.waveform && prevProps.src !== this.props.src) {
|
||||
this._updateWaveform();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.wavesurfer) {
|
||||
this.wavesurfer.destroy();
|
||||
this.wavesurfer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_updateWaveform () {
|
||||
const { src, height, duration, peaks, preload } = this.props;
|
||||
|
||||
const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
|
||||
const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
|
||||
|
||||
if (this.wavesurfer) {
|
||||
this.wavesurfer.destroy();
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
const wavesurfer = WaveSurfer.create({
|
||||
container: this.waveform,
|
||||
height,
|
||||
barWidth: 3,
|
||||
cursorWidth: 0,
|
||||
progressColor,
|
||||
waveColor,
|
||||
backend: 'MediaElement',
|
||||
interact: preload,
|
||||
});
|
||||
|
||||
wavesurfer.setVolume(this.state.volume);
|
||||
|
||||
if (preload) {
|
||||
wavesurfer.load(src);
|
||||
this.loaded = true;
|
||||
} else {
|
||||
wavesurfer.load(src, peaks, 'none', duration);
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
|
||||
wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
|
||||
wavesurfer.on('pause', () => this.setState({ paused: true }));
|
||||
wavesurfer.on('play', () => this.setState({ paused: false }));
|
||||
wavesurfer.on('volume', volume => this.setState({ volume }));
|
||||
wavesurfer.on('mute', muted => this.setState({ muted }));
|
||||
|
||||
this.wavesurfer = wavesurfer;
|
||||
}
|
||||
|
||||
togglePlay = () => {
|
||||
if (this.state.paused) {
|
||||
if (!this.props.preload && !this.loaded) {
|
||||
this.wavesurfer.createBackend();
|
||||
this.wavesurfer.createPeakCache();
|
||||
this.wavesurfer.load(this.props.src);
|
||||
this.wavesurfer.toggleInteraction();
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
this.wavesurfer.play();
|
||||
this.setState({ paused: false });
|
||||
} else {
|
||||
this.wavesurfer.pause();
|
||||
this.setState({ paused: true });
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute = () => {
|
||||
this.wavesurfer.setMute(!this.state.muted);
|
||||
}
|
||||
|
||||
handleVolumeMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||
|
||||
this.handleMouseVolSlide(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
handleVolumeMouseUp = () => {
|
||||
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||
}
|
||||
|
||||
handleMouseVolSlide = throttle(e => {
|
||||
const rect = this.volume.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.
|
||||
|
||||
if(!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if(x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
this.wavesurfer.setVolume(slideamt);
|
||||
}
|
||||
}, 60);
|
||||
|
||||
render () {
|
||||
const { height, intl, alt, editable } = this.props;
|
||||
const { paused, muted, volume, currentTime } = this.state;
|
||||
|
||||
const volumeWidth = muted ? 0 : volume * this.volWidth;
|
||||
const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
|
||||
|
||||
return (
|
||||
<div className={classNames('audio-player', { editable })}>
|
||||
<div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
|
||||
<div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
|
||||
|
||||
<div
|
||||
className='audio-player__waveform'
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
style={{ height }}
|
||||
ref={this.setWaveformRef}
|
||||
/>
|
||||
|
||||
<div className='video-player__controls active'>
|
||||
<div className='video-player__buttons-bar'>
|
||||
<div className='video-player__buttons left'>
|
||||
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
||||
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
||||
|
||||
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
||||
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
||||
|
||||
<span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
tabIndex='0'
|
||||
style={{ left: `${volumeHandleLoc}px` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
this.props.onLogout();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent {
|
||||
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
||||
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
|
||||
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
|
||||
|
||||
return (
|
||||
<div className='compose__action-bar'>
|
||||
|
@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
||||
|
||||
<div className='navigation-bar__actions'>
|
||||
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
|
||||
<ActionBar account={this.props.account} />
|
||||
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,11 +1,29 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import NavigationBar from '../components/navigation_bar';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { me } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
account: state.getIn(['accounts', me]),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NavigationBar);
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
|
||||
|
@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import SearchResultsContainer from './containers/search_results_container';
|
||||
import { changeComposing } from '../../actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||
import { mascot } from '../../initial_state';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import AnnouncementsContainer from './containers/announcements_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
@ -26,6 +28,8 @@ const messages = defineMessages({
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
|
||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
@ -62,6 +66,21 @@ class Compose extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleLogoutClick = e => {
|
||||
const { dispatch, intl } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.props.dispatch(changeComposing(true));
|
||||
}
|
||||
@ -93,7 +112,7 @@ class Compose extends React.PureComponent {
|
||||
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
|
||||
)}
|
||||
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><Icon id='sign-out' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import Avatar from 'mastodon/components/avatar';
|
||||
import DisplayName from 'mastodon/components/display_name';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
account: getAccount(state, id),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||
}));
|
||||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(blockAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onMute (account) {
|
||||
if (account.getIn(['relationship', 'muting'])) {
|
||||
dispatch(unmuteAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||
class AccountCard extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_updateEmojis () {
|
||||
const node = this.node;
|
||||
|
||||
if (!node || autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = node.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
if (emoji.classList.contains('status-emoji')) {
|
||||
continue;
|
||||
}
|
||||
emoji.classList.add('status-emoji');
|
||||
|
||||
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
|
||||
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this._updateEmojis();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
this._updateEmojis();
|
||||
}
|
||||
|
||||
handleEmojiMouseEnter = ({ target }) => {
|
||||
target.src = target.getAttribute('data-original');
|
||||
}
|
||||
|
||||
handleEmojiMouseLeave = ({ target }) => {
|
||||
target.src = target.getAttribute('data-static');
|
||||
}
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, intl } = this.props;
|
||||
|
||||
let buttons;
|
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
|
||||
} else if (blocking) {
|
||||
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||
} else if (muting) {
|
||||
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
|
||||
} else if (!account.get('moved') || following) {
|
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='directory__card'>
|
||||
<div className='directory__card__img'>
|
||||
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
|
||||
</div>
|
||||
|
||||
<div className='directory__card__bar'>
|
||||
<Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
||||
<Avatar account={account} size={48} />
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div className='directory__card__bar__relationship account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='directory__card__extra' ref={this.setRef}>
|
||||
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
|
||||
</div>
|
||||
|
||||
<div className='directory__card__extra'>
|
||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
|
||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
|
||||
<div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
171
app/javascript/mastodon/features/directory/index.js
Normal file
171
app/javascript/mastodon/features/directory/index.js
Normal file
@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
|
||||
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import AccountCard from './components/account_card';
|
||||
import RadioButton from 'mastodon/components/radio_button';
|
||||
import classNames from 'classnames';
|
||||
import LoadMore from 'mastodon/components/load_more';
|
||||
import { ScrollContainer } from 'react-router-scroll-4';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
|
||||
recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
|
||||
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
|
||||
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
|
||||
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
|
||||
isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
|
||||
domain: state.getIn(['meta', 'domain']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Directory extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
accountIds: ImmutablePropTypes.list.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
columnId: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
params: PropTypes.shape({
|
||||
order: PropTypes.string,
|
||||
local: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
|
||||
state = {
|
||||
order: null,
|
||||
local: null,
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
|
||||
}
|
||||
}
|
||||
|
||||
getParams = (props, state) => ({
|
||||
order: state.order === null ? (props.params.order || 'active') : state.order,
|
||||
local: state.local === null ? (props.params.local || false) : state.local,
|
||||
});
|
||||
|
||||
handleMove = dir => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(fetchDirectory(this.getParams(this.props, this.state)));
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps, prevState) {
|
||||
const { dispatch } = this.props;
|
||||
const paramsOld = this.getParams(prevProps, prevState);
|
||||
const paramsNew = this.getParams(this.props, this.state);
|
||||
|
||||
if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
|
||||
dispatch(fetchDirectory(paramsNew));
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
handleChangeOrder = e => {
|
||||
const { dispatch, columnId } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
|
||||
} else {
|
||||
this.setState({ order: e.target.value });
|
||||
}
|
||||
}
|
||||
|
||||
handleChangeLocal = e => {
|
||||
const { dispatch, columnId } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
|
||||
} else {
|
||||
this.setState({ local: e.target.value === '1' });
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(expandDirectory(this.getParams(this.props, this.state)));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
|
||||
const { order, local } = this.getParams(this.props, this.state);
|
||||
const pinned = !!columnId;
|
||||
|
||||
const scrollableArea = (
|
||||
<div className='scrollable' style={{ background: 'transparent' }}>
|
||||
<div className='filter-form'>
|
||||
<div className='filter-form__column' role='group'>
|
||||
<RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
|
||||
<RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
|
||||
</div>
|
||||
|
||||
<div className='filter-form__column' role='group'>
|
||||
<RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
|
||||
<RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classNames('directory__list', { loading: isLoading })}>
|
||||
{accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
|
||||
</div>
|
||||
|
||||
<LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon='address-book-o'
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class Trends extends ImmutablePureComponent {
|
||||
|
||||
@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent {
|
||||
|
||||
componentDidMount () {
|
||||
this.props.fetchTrends();
|
||||
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000);
|
||||
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<div className='getting-started__trends'>
|
||||
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
|
||||
|
||||
{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
</div>
|
||||
);
|
||||
|
@ -109,7 +109,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
if (profile_directory) {
|
||||
navItems.push(
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />,
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />,
|
||||
<ColumnLink key={i++} icon='bullhorn' text={intl.formatMessage(messages.admin_notifications)} to='/timelines/tag/Yづinfo' />,
|
||||
);
|
||||
|
||||
@ -123,7 +123,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
height += 34;
|
||||
} else if (profile_directory) {
|
||||
navItems.push(
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} href='/explore' />,
|
||||
<ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />,
|
||||
<ColumnLink key={i++} icon='bullhorn' text={intl.formatMessage(messages.admin_notifications)} to='/timelines/tag/Yづinfo' />,
|
||||
);
|
||||
|
||||
|
@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { columnId }) => ({
|
||||
},
|
||||
|
||||
onLoad (value) {
|
||||
return api().get('/api/v2/search', { params: { q: value } }).then(response => {
|
||||
return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
|
||||
return (response.data.hashtags || []).map((tag) => {
|
||||
return { value: tag.name, label: `#${tag.name}` };
|
||||
});
|
||||
|
@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
|
||||
import Card from './card';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Video from '../../video';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Audio
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={110}
|
||||
preload
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
|
@ -86,9 +86,9 @@ const makeMapStateToProps = () => {
|
||||
const getDescendantsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'replies']),
|
||||
], (statusId, contextReplies) => {
|
||||
let descendantsIds = Immutable.List();
|
||||
descendantsIds = descendantsIds.withMutations(mutable => {
|
||||
state => state.get('statuses'),
|
||||
], (statusId, contextReplies, statuses) => {
|
||||
let descendantsIds = [];
|
||||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
@ -96,7 +96,7 @@ const makeMapStateToProps = () => {
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (statusId !== id) {
|
||||
mutable.push(id);
|
||||
descendantsIds.push(id);
|
||||
}
|
||||
|
||||
if (replies) {
|
||||
@ -105,9 +105,19 @@ const makeMapStateToProps = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return descendantsIds;
|
||||
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
|
||||
if (insertAt !== -1) {
|
||||
descendantsIds.forEach((id, idx) => {
|
||||
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
|
||||
descendantsIds.splice(idx, 1);
|
||||
descendantsIds.splice(insertAt, 0, id);
|
||||
insertAt += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Immutable.List(descendantsIds);
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
|
@ -12,7 +12,19 @@ import BundleContainer from '../containers/bundle_container';
|
||||
import ColumnLoading from './column_loading';
|
||||
import DrawerLoading from './drawer_loading';
|
||||
import BundleColumnError from './bundle_column_error';
|
||||
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from '../../ui/util/async-components';
|
||||
import {
|
||||
Compose,
|
||||
Notifications,
|
||||
HomeTimeline,
|
||||
CommunityTimeline,
|
||||
PublicTimeline,
|
||||
HashtagTimeline,
|
||||
DirectTimeline,
|
||||
FavouritedStatuses,
|
||||
BookmarkedStatuses,
|
||||
ListTimeline,
|
||||
Directory,
|
||||
} from '../../ui/util/async-components';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import ComposePanel from './compose_panel';
|
||||
import NavigationPanel from './navigation_panel';
|
||||
@ -31,6 +43,7 @@ const componentMap = {
|
||||
'FAVOURITES': FavouritedStatuses,
|
||||
'BOOKMARKS': BookmarkedStatuses,
|
||||
'LIST': ListTimeline,
|
||||
'DIRECTORY': Directory,
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import Button from 'mastodon/components/button';
|
||||
import Video from 'mastodon/features/video';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
|
||||
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
|
||||
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{['audio', 'video'].includes(media.get('type')) && (
|
||||
{media.get('type') === 'video' && (
|
||||
<Video
|
||||
preview={media.get('preview_url')}
|
||||
blurhash={media.get('blurhash')}
|
||||
src={media.get('url')}
|
||||
detailed
|
||||
inline
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
||||
{media.get('type') === 'audio' && (
|
||||
<Audio
|
||||
src={media.get('url')}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={150}
|
||||
preload
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
@ -1,10 +1,50 @@
|
||||
import { connect } from 'react-redux';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
|
||||
const LinkFooter = ({ withHotkeys }) => (
|
||||
const messages = defineMessages({
|
||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
@connect(null, mapDispatchToProps)
|
||||
class LinkFooter extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
withHotkeys: PropTypes.bool,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogoutClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onLogout();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { withHotkeys } = this.props;
|
||||
|
||||
return (
|
||||
<div className='getting-started__footer'>
|
||||
<ul>
|
||||
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
|
||||
@ -15,7 +55,7 @@ const LinkFooter = ({ withHotkeys }) => (
|
||||
<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://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
|
||||
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
||||
<li><a href='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
@ -26,10 +66,7 @@ const LinkFooter = ({ withHotkeys }) => (
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
LinkFooter.propTypes = {
|
||||
withHotkeys: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default LinkFooter;
|
||||
|
@ -20,6 +20,7 @@ const NavigationPanel = () => (
|
||||
<NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='colmun-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmark' /></NavLink>
|
||||
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
|
||||
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
|
||||
{profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.profile_directory' defaultMessage='Profile directory' /></NavLink>}
|
||||
|
||||
<ListPanel />
|
||||
|
||||
@ -27,7 +28,6 @@ const NavigationPanel = () => (
|
||||
|
||||
<a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>
|
||||
<a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>
|
||||
{!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>}
|
||||
|
||||
{showTrends && <div className='flex-spacer' />}
|
||||
{showTrends && <TrendsContainer />}
|
||||
|
@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
|
||||
const value = notification[key];
|
||||
|
||||
if (typeof value === 'object') {
|
||||
notification[key] = intl.formatMessage(value);
|
||||
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
|
||||
}
|
||||
}));
|
||||
|
||||
|
@ -48,6 +48,7 @@ import {
|
||||
PinnedStatuses,
|
||||
Lists,
|
||||
Search,
|
||||
Directory,
|
||||
} from './util/async-components';
|
||||
import { me, forceSingleColumn } from '../../initial_state';
|
||||
import { previewState as previewMediaState } from './components/media_modal';
|
||||
@ -142,14 +143,24 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||
return location.state !== previewMediaState && location.state !== previewVideoState;
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
handleLayoutChange = debounce(() => {
|
||||
// The cached heights are no longer accurate, invalidate
|
||||
this.props.onLayoutChange();
|
||||
|
||||
this.setState({ mobile: isMobile(window.innerWidth) });
|
||||
}, 500, {
|
||||
trailing: true,
|
||||
});
|
||||
})
|
||||
|
||||
handleResize = () => {
|
||||
const mobile = isMobile(window.innerWidth);
|
||||
|
||||
if (mobile !== this.state.mobile) {
|
||||
this.handleLayoutChange.cancel();
|
||||
this.props.onLayoutChange();
|
||||
this.setState({ mobile });
|
||||
} else {
|
||||
this.handleLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c.getWrappedInstance();
|
||||
@ -180,6 +191,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
||||
|
||||
<WrappedRoute path='/search' component={Search} content={children} />
|
||||
<WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
||||
|
||||
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
|
||||
|
@ -141,3 +141,11 @@ export function Search () {
|
||||
export function Tesseract () {
|
||||
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
|
||||
}
|
||||
|
||||
export function Audio () {
|
||||
return import(/* webpackChunkName: "features/audio" */'../../audio');
|
||||
}
|
||||
|
||||
export function Directory () {
|
||||
return import(/* webpackChunkName: "features/directory" */'../../directory');
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ const messages = defineMessages({
|
||||
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
|
||||
});
|
||||
|
||||
const formatTime = secondsNum => {
|
||||
export const formatTime = secondsNum => {
|
||||
let hours = Math.floor(secondsNum / 3600);
|
||||
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
|
||||
let seconds = secondsNum - (hours * 3600) - (minutes * 60);
|
||||
|
@ -4,7 +4,7 @@
|
||||
"account.block": "Bluccà @{name}",
|
||||
"account.block_domain": "Piattà tuttu da {domain}",
|
||||
"account.blocked": "Bluccatu",
|
||||
"account.cancel_follow_request": "Cancel follow request",
|
||||
"account.cancel_follow_request": "Annullà a dumanda d'abbunamentu",
|
||||
"account.direct": "Missaghju direttu @{name}",
|
||||
"account.domain_blocked": "Duminiu piattatu",
|
||||
"account.edit_profile": "Mudificà u prufile",
|
||||
@ -38,7 +38,7 @@
|
||||
"account.unmute_notifications": "Ùn piattà più nutificazione da @{name}",
|
||||
"alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
|
||||
"alert.unexpected.title": "Uups!",
|
||||
"autosuggest_hashtag.per_week": "{count} per week",
|
||||
"autosuggest_hashtag.per_week": "{count} per settimana",
|
||||
"boost_modal.combo": "Pudete appughjà nant'à {combo} per saltà quessa a prussima volta",
|
||||
"bundle_column_error.body": "C'hè statu un prublemu caricandu st'elementu.",
|
||||
"bundle_column_error.retry": "Pruvà torna",
|
||||
@ -253,7 +253,7 @@
|
||||
"navigation_bar.profile_directory": "Annuariu di i prufili",
|
||||
"navigation_bar.public_timeline": "Linea pubblica glubale",
|
||||
"navigation_bar.security": "Sicurità",
|
||||
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
|
||||
"notification.and_n_others": "è {count, plural, one {# altru} other {# altri}}",
|
||||
"notification.favourite": "{name} hà aghjuntu u vostru statutu à i so favuriti",
|
||||
"notification.follow": "{name} v'hà seguitatu",
|
||||
"notification.mention": "{name} v'hà mintuvatu",
|
||||
@ -373,22 +373,22 @@
|
||||
"time_remaining.moments": "Ci fermanu qualchi mumentu",
|
||||
"time_remaining.seconds": "{number, plural, one {# siconda ferma} other {# siconde fermanu}}",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.refresh": "Attualizà",
|
||||
"ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.",
|
||||
"upload_area.title": "Drag & drop per caricà un fugliale",
|
||||
"upload_button.label": "Aghjunghje un media (JPEG, PNG, GIF, WebM, MP4, MOV)",
|
||||
"upload_error.limit": "Limita di caricamentu di fugliali trapassata.",
|
||||
"upload_error.poll": "Ùn si pò micca caricà fugliali cù i scandagli.",
|
||||
"upload_form.description": "Discrive per i malvistosi",
|
||||
"upload_form.edit": "Edit",
|
||||
"upload_form.edit": "Mudificà",
|
||||
"upload_form.undo": "Sguassà",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.analyzing_picture": "Analisi di u ritrattu…",
|
||||
"upload_modal.apply": "Affettà",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
|
||||
"upload_modal.preview_label": "Preview ({ratio})",
|
||||
"upload_modal.detect_text": "Ditettà testu da u ritrattu",
|
||||
"upload_modal.edit_media": "Cambià media",
|
||||
"upload_modal.hint": "Cliccate o sguillate u chjerchju nant'à a vista per sceglie u puntu fucale chì sarà sempre in vista indè tutte e miniature.",
|
||||
"upload_modal.preview_label": "Vista ({ratio})",
|
||||
"upload_progress.label": "Caricamentu...",
|
||||
"video.close": "Chjudà a video",
|
||||
"video.exit_fullscreen": "Caccià u pienu screnu",
|
||||
|
@ -4,7 +4,7 @@
|
||||
"account.block": "@{name} blockieren",
|
||||
"account.block_domain": "Alles von {domain} verstecken",
|
||||
"account.blocked": "Blockiert",
|
||||
"account.cancel_follow_request": "Cancel follow request",
|
||||
"account.cancel_follow_request": "Folgeanfrage abbrechen",
|
||||
"account.direct": "Direktnachricht an @{name}",
|
||||
"account.domain_blocked": "Domain versteckt",
|
||||
"account.edit_profile": "Profil bearbeiten",
|
||||
@ -38,7 +38,7 @@
|
||||
"account.unmute_notifications": "Benachrichtigungen von @{name} einschalten",
|
||||
"alert.unexpected.message": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"alert.unexpected.title": "Hoppla!",
|
||||
"autosuggest_hashtag.per_week": "{count} per week",
|
||||
"autosuggest_hashtag.per_week": "{count} pro Woche",
|
||||
"boost_modal.combo": "Drücke {combo}, um dieses Fenster zu überspringen",
|
||||
"bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
|
||||
"bundle_column_error.retry": "Erneut versuchen",
|
||||
@ -253,7 +253,7 @@
|
||||
"navigation_bar.profile_directory": "Profilverzeichnis",
|
||||
"navigation_bar.public_timeline": "Föderierte Zeitleiste",
|
||||
"navigation_bar.security": "Sicherheit",
|
||||
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
|
||||
"notification.and_n_others": "und {count, plural, one {# andere Person} other {# andere Personen}}",
|
||||
"notification.favourite": "{name} hat deinen Beitrag favorisiert",
|
||||
"notification.follow": "{name} folgt dir",
|
||||
"notification.mention": "{name} hat dich erwähnt",
|
||||
@ -373,22 +373,22 @@
|
||||
"time_remaining.moments": "Schließt in Kürze",
|
||||
"time_remaining.seconds": "{number, plural, one {# Sekunde} other {# Sekunden}} verbleibend",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, eine {Person} other {Personen}} reden darüber",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.refresh": "Aktualisieren",
|
||||
"ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
|
||||
"upload_area.title": "Zum Hochladen hereinziehen",
|
||||
"upload_button.label": "Mediendatei hinzufügen ({formats})",
|
||||
"upload_error.limit": "Dateiupload-Limit erreicht.",
|
||||
"upload_error.poll": "Dateiuploads sind in Kombination mit Umfragen nicht erlaubt.",
|
||||
"upload_form.description": "Für Menschen mit Sehbehinderung beschreiben",
|
||||
"upload_form.edit": "Edit",
|
||||
"upload_form.edit": "Bearbeiten",
|
||||
"upload_form.undo": "Löschen",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
|
||||
"upload_modal.preview_label": "Preview ({ratio})",
|
||||
"upload_modal.analyzing_picture": "Analysiere Bild…",
|
||||
"upload_modal.apply": "Übernehmen",
|
||||
"upload_modal.description_placeholder": "Franz jagt im komplett verwahrlosten Taxi quer durch Bayern",
|
||||
"upload_modal.detect_text": "Text aus Bild erkennen",
|
||||
"upload_modal.edit_media": "Medien bearbeiten",
|
||||
"upload_modal.hint": "Klicke oder ziehe den Kreis auf die Vorschau, um den Brennpunkt auszuwählen, der immer auf allen Vorschaubilder angezeigt wird.",
|
||||
"upload_modal.preview_label": "Vorschau ({ratio})",
|
||||
"upload_progress.label": "Wird hochgeladen …",
|
||||
"video.close": "Video schließen",
|
||||
"video.exit_fullscreen": "Vollbild verlassen",
|
||||
|
@ -741,6 +741,27 @@
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/account/components/header.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Play",
|
||||
"id": "video.play"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Pause",
|
||||
"id": "video.pause"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Mute sound",
|
||||
"id": "video.mute"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Unmute sound",
|
||||
"id": "video.unmute"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/audio/index.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
@ -1096,15 +1117,6 @@
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/compose/components/upload_form.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Uploading...",
|
||||
"id": "upload_progress.label"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
@ -1317,8 +1329,8 @@
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Refresh",
|
||||
"id": "trends.refresh"
|
||||
"defaultMessage": "Trending now",
|
||||
"id": "trends.trending_now"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/getting_started/components/trends.json"
|
||||
@ -1456,6 +1468,10 @@
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Basic",
|
||||
"id": "home.column_settings.basic"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Show boosts",
|
||||
"id": "home.column_settings.show_reblogs"
|
||||
@ -1837,14 +1853,6 @@
|
||||
"defaultMessage": "Push notifications",
|
||||
"id": "notifications.column_settings.push"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Basic",
|
||||
"id": "home.column_settings.basic"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Update in real-time",
|
||||
"id": "home.column_settings.update_live"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Quick filter bar",
|
||||
"id": "notifications.column_settings.filter_bar.category"
|
||||
@ -1903,10 +1911,6 @@
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "and {count, plural, one {# other} other {# others}}",
|
||||
"id": "notification.and_n_others"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "{name} followed you",
|
||||
"id": "notification.follow"
|
||||
|
@ -158,7 +158,6 @@
|
||||
"home.column_settings.basic": "Basic",
|
||||
"home.column_settings.show_reblogs": "Show boosts",
|
||||
"home.column_settings.show_replies": "Show replies",
|
||||
"home.column_settings.update_live": "Update in real-time",
|
||||
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
|
||||
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
|
||||
@ -253,7 +252,6 @@
|
||||
"navigation_bar.profile_directory": "Profile directory",
|
||||
"navigation_bar.public_timeline": "Federated timeline",
|
||||
"navigation_bar.security": "Security",
|
||||
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
|
||||
"notification.favourite": "{name} favourited your status",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
@ -373,7 +371,7 @@
|
||||
"time_remaining.moments": "Moments remaining",
|
||||
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.trending_now": "Trending now",
|
||||
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
|
||||
"upload_area.title": "Drag & drop to upload",
|
||||
"upload_button.label": "Add media ({formats})",
|
||||
|
@ -4,7 +4,7 @@
|
||||
"account.block": "@{name}さんをブロック",
|
||||
"account.block_domain": "{domain}全体を非表示",
|
||||
"account.blocked": "ブロック済み",
|
||||
"account.cancel_follow_request": "Cancel follow request",
|
||||
"account.cancel_follow_request": "フォローリクエストを取り消す",
|
||||
"account.direct": "@{name}さんにダイレクトメッセージ",
|
||||
"account.domain_blocked": "ドメイン非表示中",
|
||||
"account.edit_profile": "プロフィール編集",
|
||||
@ -38,7 +38,7 @@
|
||||
"account.unmute_notifications": "@{name}さんからの通知を受け取るようにする",
|
||||
"alert.unexpected.message": "不明なエラーが発生しました。",
|
||||
"alert.unexpected.title": "エラー!",
|
||||
"autosuggest_hashtag.per_week": "{count} per week",
|
||||
"autosuggest_hashtag.per_week": "{count} 回 / 週",
|
||||
"boost_modal.combo": "次からは{combo}を押せばスキップできます",
|
||||
"bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。",
|
||||
"bundle_column_error.retry": "再試行",
|
||||
@ -381,22 +381,22 @@
|
||||
"time_remaining.moments": "まもなく終了",
|
||||
"time_remaining.seconds": "残り{number}秒",
|
||||
"trends.count_by_accounts": "{count}人がトゥート",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.refresh": "更新",
|
||||
"ui.beforeunload": "Mastodonから離れると送信前の投稿は失われます。",
|
||||
"upload_area.title": "ドラッグ&ドロップでアップロード",
|
||||
"upload_button.label": "メディアを追加 ({formats})",
|
||||
"upload_error.limit": "アップロードできる上限を超えています。",
|
||||
"upload_error.poll": "アンケートではファイルをアップロードできません。",
|
||||
"upload_form.description": "視覚障害者のための説明",
|
||||
"upload_form.edit": "Edit",
|
||||
"upload_form.edit": "編集",
|
||||
"upload_form.undo": "削除",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.analyzing_picture": "画像を解析中…",
|
||||
"upload_modal.apply": "適用",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
|
||||
"upload_modal.preview_label": "Preview ({ratio})",
|
||||
"upload_modal.detect_text": "画像からテキストを検出",
|
||||
"upload_modal.edit_media": "メディアを編集",
|
||||
"upload_modal.hint": "画像をクリックするか円をドラッグすると全てのサムネイルで注目する場所を選ぶことができます",
|
||||
"upload_modal.preview_label": "プレビュー ({ratio})",
|
||||
"upload_progress.label": "アップロード中...",
|
||||
"video.close": "動画を閉じる",
|
||||
"video.exit_fullscreen": "全画面を終了する",
|
||||
|
@ -4,7 +4,7 @@
|
||||
"account.block": "@{name}을 차단",
|
||||
"account.block_domain": "{domain} 전체를 숨김",
|
||||
"account.blocked": "차단 됨",
|
||||
"account.cancel_follow_request": "Cancel follow request",
|
||||
"account.cancel_follow_request": "팔로우 요청 취소",
|
||||
"account.direct": "@{name}으로부터의 다이렉트 메시지",
|
||||
"account.domain_blocked": "도메인 숨겨짐",
|
||||
"account.edit_profile": "프로필 편집",
|
||||
@ -38,7 +38,7 @@
|
||||
"account.unmute_notifications": "@{name}의 알림 뮤트 해제",
|
||||
"alert.unexpected.message": "예측하지 못한 에러가 발생했습니다.",
|
||||
"alert.unexpected.title": "앗!",
|
||||
"autosuggest_hashtag.per_week": "{count} per week",
|
||||
"autosuggest_hashtag.per_week": "주간 {count}회",
|
||||
"boost_modal.combo": "{combo}를 누르면 다음부터 이 과정을 건너뛸 수 있습니다",
|
||||
"bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
|
||||
"bundle_column_error.retry": "다시 시도",
|
||||
@ -253,7 +253,7 @@
|
||||
"navigation_bar.profile_directory": "프로필 디렉토리",
|
||||
"navigation_bar.public_timeline": "연합 타임라인",
|
||||
"navigation_bar.security": "보안",
|
||||
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
|
||||
"notification.and_n_others": "그리고 {count}개의 기타 항목",
|
||||
"notification.favourite": "{name}님이 즐겨찾기 했습니다",
|
||||
"notification.follow": "{name}님이 나를 팔로우 했습니다",
|
||||
"notification.mention": "{name}님이 답글을 보냈습니다",
|
||||
@ -373,22 +373,22 @@
|
||||
"time_remaining.moments": "남은 시간",
|
||||
"time_remaining.seconds": "{number} 초 남음",
|
||||
"trends.count_by_accounts": "{count} 명의 사람들이 말하고 있습니다",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.refresh": "새로고침",
|
||||
"ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.",
|
||||
"upload_area.title": "드래그 & 드롭으로 업로드",
|
||||
"upload_button.label": "미디어 추가 (JPEG, PNG, GIF, WebM, MP4, MOV)",
|
||||
"upload_error.limit": "파일 업로드 제한에 도달했습니다.",
|
||||
"upload_error.poll": "파일 업로드는 투표와 함께 첨부할 수 없습니다.",
|
||||
"upload_form.description": "시각장애인을 위한 설명",
|
||||
"upload_form.edit": "Edit",
|
||||
"upload_form.edit": "편집",
|
||||
"upload_form.undo": "삭제",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
|
||||
"upload_modal.preview_label": "Preview ({ratio})",
|
||||
"upload_modal.analyzing_picture": "이미지 분석 중…",
|
||||
"upload_modal.apply": "적용",
|
||||
"upload_modal.description_placeholder": "다람쥐 헌 쳇바퀴 타고파",
|
||||
"upload_modal.detect_text": "이미지에서 텍스트 추출",
|
||||
"upload_modal.edit_media": "미디어 편집",
|
||||
"upload_modal.hint": "미리보기를 클릭하거나 드래그 해서 포컬 포인트를 맞추세요. 이 점은 썸네일에 항상 보여질 부분을 나타냅니다.",
|
||||
"upload_modal.preview_label": "미리보기 ({ratio})",
|
||||
"upload_progress.label": "업로드 중...",
|
||||
"video.close": "동영상 닫기",
|
||||
"video.exit_fullscreen": "전체화면 나가기",
|
||||
|
@ -4,7 +4,7 @@
|
||||
"account.block": "Блокировать",
|
||||
"account.block_domain": "Блокировать все с {domain}",
|
||||
"account.blocked": "Заблокирован(а)",
|
||||
"account.cancel_follow_request": "Cancel follow request",
|
||||
"account.cancel_follow_request": "Отменить запрос",
|
||||
"account.direct": "Написать @{name}",
|
||||
"account.domain_blocked": "Домен скрыт",
|
||||
"account.edit_profile": "Изменить профиль",
|
||||
@ -38,7 +38,7 @@
|
||||
"account.unmute_notifications": "Показывать уведомления от @{name}",
|
||||
"alert.unexpected.message": "Что-то пошло не так.",
|
||||
"alert.unexpected.title": "Ой!",
|
||||
"autosuggest_hashtag.per_week": "{count} per week",
|
||||
"autosuggest_hashtag.per_week": "{count} / неделю",
|
||||
"boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
|
||||
"bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
|
||||
"bundle_column_error.retry": "Попробовать снова",
|
||||
@ -255,8 +255,8 @@
|
||||
"navigation_bar.security": "Безопасность",
|
||||
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
|
||||
"notification.favourite": "{name} понравился Ваш статус",
|
||||
"notification.follow": "{name} подписался(-лась) на Вас",
|
||||
"notification.mention": "{name} упомянул(а) Вас",
|
||||
"notification.follow": "{name} подписался (-лась) на вас",
|
||||
"notification.mention": "{name} упомянул(а) вас",
|
||||
"notification.poll": "Опрос, в котором вы приняли участие, завершился",
|
||||
"notification.reblog": "{name} продвинул(а) Ваш статус",
|
||||
"notifications.clear": "Очистить уведомления",
|
||||
@ -373,22 +373,22 @@
|
||||
"time_remaining.moments": "остались считанные мгновения",
|
||||
"time_remaining.seconds": "{number, plural, one {осталась # секунду} few {осталось # секунды} many {осталось # секунд} other {осталось # секунд}}",
|
||||
"trends.count_by_accounts": "Популярно у {count} {rawCount, plural, one {человека} few {человек} many {человек} other {человек}}",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.refresh": "Обновить",
|
||||
"ui.beforeunload": "Ваш черновик будет утерян, если вы покинете Mastodon.",
|
||||
"upload_area.title": "Перетащите сюда, чтобы загрузить",
|
||||
"upload_button.label": "Добавить медиаконтент",
|
||||
"upload_error.limit": "Достигнут лимит загруженных файлов.",
|
||||
"upload_error.poll": "К опросам нельзя прикреплять файлы.",
|
||||
"upload_form.description": "Описать для людей с нарушениями зрения",
|
||||
"upload_form.edit": "Edit",
|
||||
"upload_form.description": "Добавьте описание для людей с нарушениями зрения:",
|
||||
"upload_form.edit": "Изменить",
|
||||
"upload_form.undo": "Отменить",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
|
||||
"upload_modal.preview_label": "Preview ({ratio})",
|
||||
"upload_modal.analyzing_picture": "Обработка изображения…",
|
||||
"upload_modal.apply": "Применить",
|
||||
"upload_modal.description_placeholder": "На дворе трава, на траве дрова",
|
||||
"upload_modal.detect_text": "Найти текст на картинке",
|
||||
"upload_modal.edit_media": "Изменение медиа",
|
||||
"upload_modal.hint": "Нажмите и перетащите круг в предпросмотре в точку фокуса, которая всегда будет видна на эскизах.",
|
||||
"upload_modal.preview_label": "Предпросмотр ({ratio})",
|
||||
"upload_progress.label": "Загрузка...",
|
||||
"video.close": "Закрыть видео",
|
||||
"video.exit_fullscreen": "Покинуть полноэкранный режим",
|
||||
|
@ -4,7 +4,7 @@
|
||||
"account.block": "Blokuj @{name}",
|
||||
"account.block_domain": "Ukry všetko z {domain}",
|
||||
"account.blocked": "Blokovaný/á",
|
||||
"account.cancel_follow_request": "Cancel follow request",
|
||||
"account.cancel_follow_request": "Zruš požiadanie o sledovanie",
|
||||
"account.direct": "Súkromná správa pre @{name}",
|
||||
"account.domain_blocked": "Doména ukrytá",
|
||||
"account.edit_profile": "Uprav profil",
|
||||
@ -24,7 +24,7 @@
|
||||
"account.mute": "Ignorovať @{name}",
|
||||
"account.mute_notifications": "Stĺm oboznámenia od @{name}",
|
||||
"account.muted": "Utíšený/á",
|
||||
"account.posts": "Príspevky",
|
||||
"account.posts": "Príspevkov",
|
||||
"account.posts_with_replies": "Príspevky aj s odpoveďami",
|
||||
"account.report": "Nahlás @{name}",
|
||||
"account.requested": "Čaká na schválenie. Klikni pre zrušenie žiadosti",
|
||||
@ -38,7 +38,7 @@
|
||||
"account.unmute_notifications": "Zruš stĺmenie oboznámení od @{name}",
|
||||
"alert.unexpected.message": "Vyskytla sa nečakaná chyba.",
|
||||
"alert.unexpected.title": "Ups!",
|
||||
"autosuggest_hashtag.per_week": "{count} per week",
|
||||
"autosuggest_hashtag.per_week": "{count} týždenne",
|
||||
"boost_modal.combo": "Nabudúce môžeš kliknúť {combo} pre preskočenie",
|
||||
"bundle_column_error.body": "Pri načítaní tohto prvku nastala nejaká chyba.",
|
||||
"bundle_column_error.retry": "Skús to znova",
|
||||
@ -142,7 +142,7 @@
|
||||
"getting_started.directory": "Zoznam profilov",
|
||||
"getting_started.documentation": "Dokumentácia",
|
||||
"getting_started.heading": "Začni tu",
|
||||
"getting_started.invite": "Pozvať ľudí",
|
||||
"getting_started.invite": "Pozvi ľudí",
|
||||
"getting_started.open_source_notice": "Mastodon je softvér s otvoreným kódom. Nahlásiť chyby, alebo prispievať môžeš na GitHube v {github}.",
|
||||
"getting_started.security": "Zabezpečenie",
|
||||
"getting_started.terms": "Podmienky prevozu",
|
||||
@ -253,7 +253,7 @@
|
||||
"navigation_bar.profile_directory": "Katalóg profilov",
|
||||
"navigation_bar.public_timeline": "Federovaná časová os",
|
||||
"navigation_bar.security": "Zabezbečenie",
|
||||
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
|
||||
"notification.and_n_others": "a {count, plural,one {# ostatní} other {# ostatných}}",
|
||||
"notification.favourite": "{name} si obľúbil/a tvoj príspevok",
|
||||
"notification.follow": "{name} ťa začal/a následovať",
|
||||
"notification.mention": "{name} ťa spomenul/a",
|
||||
@ -281,7 +281,7 @@
|
||||
"notifications.filter.polls": "Výsledky ankiet",
|
||||
"notifications.group": "{count} oboznámení",
|
||||
"poll.closed": "Uzatvorená",
|
||||
"poll.refresh": "Aktualizuj",
|
||||
"poll.refresh": "Obnov",
|
||||
"poll.total_votes": "{count, plural, one {# hlas} few {# hlasov} many {# hlasov} other {# hlasov}}",
|
||||
"poll.vote": "Hlasuj",
|
||||
"poll_button.add_poll": "Pridaj anketu",
|
||||
@ -373,20 +373,20 @@
|
||||
"time_remaining.moments": "Ostáva už iba chviľka",
|
||||
"time_remaining.seconds": "Ostáva {number, plural, one {# sekunda} few {# sekúnd} many {# sekúnd} other {# sekúnd}}",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {človek vraví} other {ľudia vravia}}",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.refresh": "Obnov",
|
||||
"ui.beforeunload": "Čo máš rozpísané sa stratí, ak opustíš Mastodon.",
|
||||
"upload_area.title": "Pretiahni a pusť pre nahratie",
|
||||
"upload_button.label": "Pridaj médiálny súbor (JPEG, PNG, GIF, WebM, MP4, MOV)",
|
||||
"upload_error.limit": "Limit pre nahrávanie súborov bol prekročený.",
|
||||
"upload_error.poll": "Nahrávanie súborov pri anketách nieje možné.",
|
||||
"upload_form.description": "Opis pre slabo vidiacich",
|
||||
"upload_form.edit": "Edit",
|
||||
"upload_form.edit": "Uprav",
|
||||
"upload_form.undo": "Vymaž",
|
||||
"upload_modal.analyzing_picture": "Analyzing picture…",
|
||||
"upload_modal.apply": "Apply",
|
||||
"upload_modal.apply": "Použi",
|
||||
"upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
|
||||
"upload_modal.detect_text": "Detect text from picture",
|
||||
"upload_modal.edit_media": "Edit media",
|
||||
"upload_modal.edit_media": "Uprav médiá",
|
||||
"upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
|
||||
"upload_modal.preview_label": "Preview ({ratio})",
|
||||
"upload_progress.label": "Nahráva sa...",
|
||||
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"account.add_or_remove_from_list": "從名單中新增或移除",
|
||||
"account.add_or_remove_from_list": "從列表新增或移除",
|
||||
"account.badges.bot": "機器人",
|
||||
"account.block": "封鎖 @{name}",
|
||||
"account.block_domain": "隱藏來自 {domain} 的所有嘟文",
|
||||
"account.block_domain": "隱藏來自 {domain} 的所有內容",
|
||||
"account.blocked": "已封鎖",
|
||||
"account.cancel_follow_request": "Cancel follow request",
|
||||
"account.direct": "傳私訊給 @{name}",
|
||||
@ -11,13 +11,13 @@
|
||||
"account.endorse": "在個人資料推薦對方",
|
||||
"account.follow": "關注",
|
||||
"account.followers": "關注者",
|
||||
"account.followers.empty": "還沒有人關注這位使用者。",
|
||||
"account.followers.empty": "尚沒有人關注這位使用者。",
|
||||
"account.follows": "正在關注",
|
||||
"account.follows.empty": "這個使用者尚未關注任何使用者。",
|
||||
"account.follows.empty": "這位使用者尚未關注任何使用者。",
|
||||
"account.follows_you": "關注了你",
|
||||
"account.hide_reblogs": "隱藏來自 @{name} 的轉推",
|
||||
"account.link_verified_on": "此連結的所有權已在 {date} 檢查",
|
||||
"account.locked_info": "此帳號的隱私狀態被設為鎖定,擁有者將手動審核可關注此帳號的人。",
|
||||
"account.link_verified_on": "已在 {date} 檢查此連結的擁有者權限",
|
||||
"account.locked_info": "這隻帳戶的隱私狀態被設成鎖定。該擁有者會手動審核能關注這隻帳號的人。",
|
||||
"account.media": "媒體",
|
||||
"account.mention": "提及 @{name}",
|
||||
"account.moved_to": "{name} 已遷移至:",
|
||||
@ -34,23 +34,23 @@
|
||||
"account.unblock_domain": "取消隱藏 {domain}",
|
||||
"account.unendorse": "不再於個人資料頁面推薦對方",
|
||||
"account.unfollow": "取消關注",
|
||||
"account.unmute": "不再靜音 @{name}",
|
||||
"account.unmute_notifications": "不再靜音來自 @{name} 的通知",
|
||||
"account.unmute": "取消靜音 @{name}",
|
||||
"account.unmute_notifications": "重新接收來自 @{name} 的通知",
|
||||
"alert.unexpected.message": "發生了非預期的錯誤。",
|
||||
"alert.unexpected.title": "哎呀!",
|
||||
"autosuggest_hashtag.per_week": "{count} per week",
|
||||
"boost_modal.combo": "下次您可以按 {combo} 跳過",
|
||||
"bundle_column_error.body": "載入此組件時發生錯誤。",
|
||||
"bundle_column_error.body": "載入此元件時發生錯誤。",
|
||||
"bundle_column_error.retry": "重試",
|
||||
"bundle_column_error.title": "網路錯誤",
|
||||
"bundle_modal_error.close": "關閉",
|
||||
"bundle_modal_error.message": "載入此組件時發生錯誤。",
|
||||
"bundle_modal_error.message": "載入此元件時發生錯誤。",
|
||||
"bundle_modal_error.retry": "重試",
|
||||
"column.blocks": "封鎖的使用者",
|
||||
"column.community": "本地時間軸",
|
||||
"column.community": "本機時間軸",
|
||||
"column.direct": "私訊",
|
||||
"column.domain_blocks": "隱藏的網域",
|
||||
"column.favourites": "最愛",
|
||||
"column.favourites": "收藏",
|
||||
"column.follow_requests": "關注請求",
|
||||
"column.home": "主頁",
|
||||
"column.lists": "名單",
|
||||
@ -66,44 +66,44 @@
|
||||
"column_header.show_settings": "顯示設定",
|
||||
"column_header.unpin": "取消釘選",
|
||||
"column_subheading.settings": "設定",
|
||||
"community.column_settings.media_only": "僅媒體",
|
||||
"compose_form.direct_message_warning": "這條嘟文只有被提及的使用者才能看到。",
|
||||
"community.column_settings.media_only": "只有媒體",
|
||||
"compose_form.direct_message_warning": "這條嘟文只有被提及的使用者才看得到。",
|
||||
"compose_form.direct_message_warning_learn_more": "了解更多",
|
||||
"compose_form.hashtag_warning": "因這則嘟文設成「不公開」,因此它不會列在任何「#」標籤下。只有公開嘟文才能用「#」標籤找到。",
|
||||
"compose_form.lock_disclaimer": "您的帳戶尚未{locked}。任何人都能關注您並看到您設定成僅關注者能看的嘟文。",
|
||||
"compose_form.hashtag_warning": "由於這則嘟文被設定成「不公開」,所以它將不會被列在任何主題標籤下。只有公開的嘟文才能藉主題標籤找到。",
|
||||
"compose_form.lock_disclaimer": "您的帳戶尚未{locked}。任何人都能關注您並看到您設定成只有關注者能看的嘟文。",
|
||||
"compose_form.lock_disclaimer.lock": "上鎖",
|
||||
"compose_form.placeholder": "您正在想些什麼?",
|
||||
"compose_form.poll.add_option": "新增選擇",
|
||||
"compose_form.poll.duration": "投票期限",
|
||||
"compose_form.poll.option_placeholder": "第 {number} 個選擇",
|
||||
"compose_form.poll.remove_option": "移除此選擇",
|
||||
"compose_form.publish": "嘟掉",
|
||||
"compose_form.publish": "嘟出去",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive.hide": "Mark media as sensitive",
|
||||
"compose_form.sensitive.marked": "此媒體被標記為敏感內容",
|
||||
"compose_form.sensitive.unmarked": "此媒體未被標記為敏感內容",
|
||||
"compose_form.spoiler.marked": "正文已隱藏在警告之後",
|
||||
"compose_form.sensitive.unmarked": "此媒體未標記為敏感內容",
|
||||
"compose_form.spoiler.marked": "正文已隱藏到警告之後",
|
||||
"compose_form.spoiler.unmarked": "正文未被隱藏",
|
||||
"compose_form.spoiler_placeholder": "請在此處寫入警告訊息",
|
||||
"confirmation_modal.cancel": "取消",
|
||||
"confirmations.block.block_and_report": "Block & Report",
|
||||
"confirmations.block.confirm": "封鎖",
|
||||
"confirmations.block.message": "你確定要封鎖 {name} ?",
|
||||
"confirmations.block.message": "確定封鎖 {name} ?",
|
||||
"confirmations.delete.confirm": "刪除",
|
||||
"confirmations.delete.message": "你確定要刪除這條嘟文?",
|
||||
"confirmations.delete_list.confirm": "刪除",
|
||||
"confirmations.delete_list.message": "確定要永久刪除此名單?",
|
||||
"confirmations.delete_list.message": "確定永久刪除此名單?",
|
||||
"confirmations.domain_block.confirm": "隱藏整個網域",
|
||||
"confirmations.domain_block.message": "確定封鎖整個 {domain} 嗎?多數情況下,封鎖或靜音幾個特定使用者應該就能滿足你的需求了。您將不能在任何公開時間軸或通知中看到來自該網域的內容。來自該網域的關注者將被移除。",
|
||||
"confirmations.domain_block.message": "真的非常確定封鎖整個 {domain} 嗎?大部分情況下,你只需要封鎖或靜音少數特定的人就能滿足需求了。你將不能在任何公開的時間軸及通知中看到那個網域的內容。你來自該網域的關注者也會被移除。",
|
||||
"confirmations.mute.confirm": "靜音",
|
||||
"confirmations.mute.message": "確定靜音 {name} ?",
|
||||
"confirmations.redraft.confirm": "刪除並重新編輯",
|
||||
"confirmations.redraft.message": "你確定要刪除這條嘟文並重新編輯它嗎?這麼做將失去轉嘟和最愛,而對原始嘟文的回覆將被孤立。",
|
||||
"confirmations.redraft.message": "確定刪掉這則嘟文並重新編輯嗎?將會失去這則嘟文的轉嘟及收藏,且回覆這則的嘟文將會變成獨立的嘟文。",
|
||||
"confirmations.reply.confirm": "回覆",
|
||||
"confirmations.reply.message": "現在回覆將蓋掉您目前正在撰寫的訊息。是否仍要回覆?",
|
||||
"confirmations.unfollow.confirm": "取消關注",
|
||||
"confirmations.unfollow.message": "真的要取消關注 {name} 嗎?",
|
||||
"embed.instructions": "要嵌入此嘟文,請將以下代碼貼進你的網站。",
|
||||
"embed.instructions": "要嵌入此嘟文,請將以下程式碼貼進你的網站。",
|
||||
"embed.preview": "他會顯示成這樣:",
|
||||
"emoji_button.activity": "活動",
|
||||
"emoji_button.custom": "自訂",
|
||||
@ -111,7 +111,7 @@
|
||||
"emoji_button.food": "飲食",
|
||||
"emoji_button.label": "插入表情符號",
|
||||
"emoji_button.nature": "大自然",
|
||||
"emoji_button.not_found": "就沒這表情符號吼!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.not_found": "啊就沒這表情符號吼!! (╯°□°)╯︵ ┻━┻",
|
||||
"emoji_button.objects": "物件",
|
||||
"emoji_button.people": "使用者",
|
||||
"emoji_button.recent": "最常使用",
|
||||
@ -125,15 +125,15 @@
|
||||
"empty_column.community": "本地時間軸是空的。快公開嘟些文搶頭香啊!",
|
||||
"empty_column.direct": "您還沒有任何私訊。當您私訊別人或收到私訊時,它將於此顯示。",
|
||||
"empty_column.domain_blocks": "尚未隱藏任何網域。",
|
||||
"empty_column.favourited_statuses": "你還沒有將任何嘟文標為最愛。最愛的嘟文將顯示於此。",
|
||||
"empty_column.favourites": "還沒有人將此嘟文標為最愛。如果有人標成最愛,則會顯示在這裡。",
|
||||
"empty_column.follow_requests": "您尚未收到任何關注請求。收到時會顯示於此。",
|
||||
"empty_column.hashtag": "這個「#」標籤下什麼都沒有。",
|
||||
"empty_column.favourited_statuses": "你還沒收藏任何嘟文。這裡將會顯示你收藏的嘟文。",
|
||||
"empty_column.favourites": "還沒有人收藏這則嘟文。這裡將會顯示被收藏的嘟文。",
|
||||
"empty_column.follow_requests": "您尚未收到任何關注請求。這裡將會顯示收到的關注請求。",
|
||||
"empty_column.hashtag": "這個主題標籤下什麼也沒有。",
|
||||
"empty_column.home": "您的首頁時間軸是空的!前往 {public} 或使用搜尋功能來認識其他人。",
|
||||
"empty_column.home.public_timeline": "公開時間軸",
|
||||
"empty_column.list": "此份名單還沒有東西。當此名單的成員嘟出了新的嘟文時,它們就會出現在這裡。",
|
||||
"empty_column.lists": "你還沒有建立任何名單。你建立的名單將會顯示在這裡。",
|
||||
"empty_column.mutes": "你還沒有靜音任何使用者。",
|
||||
"empty_column.list": "這份名單還沒有東西。當此名單的成員嘟出了新的嘟文時,它們就會顯示於此。",
|
||||
"empty_column.lists": "你還沒有建立任何名單。這裡將會顯示你所建立的名單。",
|
||||
"empty_column.mutes": "你尚未靜音任何使用者。",
|
||||
"empty_column.notifications": "您尚未收到任何通知,和別人互動開啟對話吧。",
|
||||
"empty_column.public": "這裡什麼都沒有!嘗試寫些公開的嘟文,或著自己關注其他伺服器的使用者後就會有嘟文出現了",
|
||||
"follow_request.authorize": "授權",
|
||||
@ -148,61 +148,61 @@
|
||||
"getting_started.terms": "服務條款",
|
||||
"hashtag.column_header.tag_mode.all": "以及{additional}",
|
||||
"hashtag.column_header.tag_mode.any": "或是{additional}",
|
||||
"hashtag.column_header.tag_mode.none": "而不用{additional}",
|
||||
"hashtag.column_header.tag_mode.none": "而無需{additional}",
|
||||
"hashtag.column_settings.select.no_options_message": "找不到建議",
|
||||
"hashtag.column_settings.select.placeholder": "輸入「#」標籤…",
|
||||
"hashtag.column_settings.select.placeholder": "輸入主題標籤…",
|
||||
"hashtag.column_settings.tag_mode.all": "全部",
|
||||
"hashtag.column_settings.tag_mode.any": "任一",
|
||||
"hashtag.column_settings.tag_mode.none": "全都不要",
|
||||
"hashtag.column_settings.tag_toggle": "對此欄位加入額外標籤",
|
||||
"hashtag.column_settings.tag_mode.none": "全不",
|
||||
"hashtag.column_settings.tag_toggle": "將額外標籤加入到這個欄位",
|
||||
"home.column_settings.basic": "基本",
|
||||
"home.column_settings.show_reblogs": "顯示轉推",
|
||||
"home.column_settings.show_reblogs": "顯示轉嘟",
|
||||
"home.column_settings.show_replies": "顯示回覆",
|
||||
"home.column_settings.update_live": "Update in real-time",
|
||||
"intervals.full.days": "{number, plural, one {# 天} other {# 天}}",
|
||||
"intervals.full.hours": "{number, plural, one {# 小時} other {# 小時}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# 分鐘} other {# 分鐘}}",
|
||||
"introduction.federation.action": "下一步",
|
||||
"introduction.federation.federated.headline": "聯邦",
|
||||
"introduction.federation.federated.text": "來自聯邦網路中其他伺服器的公開嘟文將會在聯邦網路時間軸中顯示。",
|
||||
"introduction.federation.federated.headline": "站台聯盟",
|
||||
"introduction.federation.federated.text": "來自聯盟宇宙中其他站台的公開嘟文將會在站點聯盟時間軸中顯示。",
|
||||
"introduction.federation.home.headline": "首頁",
|
||||
"introduction.federation.home.text": "您所關注使用者所發的嘟文將顯示在首頁的訊息來源。您能關注任何伺服器上的任何人!",
|
||||
"introduction.federation.local.headline": "本地",
|
||||
"introduction.federation.local.text": "跟您同伺服器之使用者所發的公開嘟文將會顯示在本地時間軸中。",
|
||||
"introduction.federation.home.text": "你關注使用者的嘟文將會在首頁動態中顯示。你可以關注任何伺服器上的任何人!",
|
||||
"introduction.federation.local.headline": "本機",
|
||||
"introduction.federation.local.text": "跟您同伺服器之使用者所發的公開嘟文將會顯示在本機時間軸中。",
|
||||
"introduction.interactions.action": "完成教學!",
|
||||
"introduction.interactions.favourite.headline": "最愛",
|
||||
"introduction.interactions.favourite.text": "您能稍候儲存嘟文,或者將嘟文加到最愛,讓作者知道您喜歡這嘟文。",
|
||||
"introduction.interactions.favourite.headline": "關注",
|
||||
"introduction.interactions.favourite.text": "您能儲存嘟文供稍候觀看,或者收藏嘟文,讓作者知道您喜歡這則嘟文。",
|
||||
"introduction.interactions.reblog.headline": "轉嘟",
|
||||
"introduction.interactions.reblog.text": "您能透過轉嘟他人嘟文來分享給您的關注者。",
|
||||
"introduction.interactions.reblog.text": "您能藉由轉嘟他人嘟文來分享給您的關注者。",
|
||||
"introduction.interactions.reply.headline": "回覆",
|
||||
"introduction.interactions.reply.text": "您能回覆其他人或自己的嘟文。將會把這些回覆串成一串對話。",
|
||||
"introduction.welcome.action": "開始!",
|
||||
"introduction.interactions.reply.text": "您能回覆其他人或自己的嘟文,這麼做會把這些回覆串成一串對話。",
|
||||
"introduction.welcome.action": "開始旅程吧!",
|
||||
"introduction.welcome.headline": "第一步",
|
||||
"introduction.welcome.text": "歡迎來到聯邦!稍候您將可以廣播訊息並跨各種各式各樣的伺服器與朋友聊天。但這台伺服器,{domain},十分特殊 -- 它寄管了您的個人資料,所以請記住這台伺服器的名稱。",
|
||||
"introduction.welcome.text": "歡迎來到聯盟宇宙!等等你就可以廣播訊息及跨越各種各式各樣的伺服器與朋友聊天。但這台伺服器,{domain},非常特別 - 它寄管了你的個人資料,所以請記住它的名字。",
|
||||
"keyboard_shortcuts.back": "返回上一頁",
|
||||
"keyboard_shortcuts.blocked": "開啟「封鎖的使用者」名單",
|
||||
"keyboard_shortcuts.blocked": "開啟「封鎖使用者」名單",
|
||||
"keyboard_shortcuts.boost": "轉嘟",
|
||||
"keyboard_shortcuts.column": "將焦點放在其中一欄的嘟文",
|
||||
"keyboard_shortcuts.compose": "將焦點移至撰寫文字區塊",
|
||||
"keyboard_shortcuts.description": "描述",
|
||||
"keyboard_shortcuts.direct": "開啟私訊欄",
|
||||
"keyboard_shortcuts.down": "在名單中往下移動",
|
||||
"keyboard_shortcuts.down": "往下移動名單項目",
|
||||
"keyboard_shortcuts.enter": "檢視嘟文",
|
||||
"keyboard_shortcuts.favourite": "加入最愛",
|
||||
"keyboard_shortcuts.favourites": "開啟最愛名單",
|
||||
"keyboard_shortcuts.federated": "開啟聯邦時間軸",
|
||||
"keyboard_shortcuts.favourite": "收藏",
|
||||
"keyboard_shortcuts.favourites": "開啟收藏名單",
|
||||
"keyboard_shortcuts.federated": "開啟站點聯盟時間軸",
|
||||
"keyboard_shortcuts.heading": "鍵盤快速鍵",
|
||||
"keyboard_shortcuts.home": "開啟首頁時間軸",
|
||||
"keyboard_shortcuts.hotkey": "快速鍵",
|
||||
"keyboard_shortcuts.legend": "顯示此說明",
|
||||
"keyboard_shortcuts.local": "開啟本地時間軸",
|
||||
"keyboard_shortcuts.legend": "顯示此列表",
|
||||
"keyboard_shortcuts.local": "開啟本機時間軸",
|
||||
"keyboard_shortcuts.mention": "提及作者",
|
||||
"keyboard_shortcuts.muted": "開啟靜音使用者名單",
|
||||
"keyboard_shortcuts.my_profile": "開啟個人資料頁面",
|
||||
"keyboard_shortcuts.notifications": "開啟通知欄",
|
||||
"keyboard_shortcuts.pinned": "開啟釘選的嘟文名單",
|
||||
"keyboard_shortcuts.profile": "開啟作者的個人資料頁",
|
||||
"keyboard_shortcuts.reply": "回應嘟文",
|
||||
"keyboard_shortcuts.profile": "開啟作者的個人資料頁面",
|
||||
"keyboard_shortcuts.reply": "回覆",
|
||||
"keyboard_shortcuts.requests": "開啟關注請求名單",
|
||||
"keyboard_shortcuts.search": "將焦點移至搜尋框",
|
||||
"keyboard_shortcuts.start": "開啟「開始使用」欄位",
|
||||
@ -210,7 +210,7 @@
|
||||
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
|
||||
"keyboard_shortcuts.toot": "開始發出新嘟文",
|
||||
"keyboard_shortcuts.unfocus": "取消輸入文字區塊 / 搜尋的焦點",
|
||||
"keyboard_shortcuts.up": "在名單中往上移動",
|
||||
"keyboard_shortcuts.up": "往上移動名單項目",
|
||||
"lightbox.close": "關閉",
|
||||
"lightbox.next": "下一步",
|
||||
"lightbox.previous": "上一步",
|
||||
@ -229,16 +229,16 @@
|
||||
"media_gallery.toggle_visible": "切換可見性",
|
||||
"missing_indicator.label": "找不到",
|
||||
"missing_indicator.sublabel": "找不到此資源",
|
||||
"mute_modal.hide_notifications": "隱藏來自這個使用者的通知?",
|
||||
"navigation_bar.apps": "行動應用程式",
|
||||
"navigation_bar.blocks": "封鎖的使用者",
|
||||
"navigation_bar.community_timeline": "本地時間軸",
|
||||
"mute_modal.hide_notifications": "隱藏來自這位使用者的通知?",
|
||||
"navigation_bar.apps": "封鎖的使用者",
|
||||
"navigation_bar.blocks": "封鎖使用者",
|
||||
"navigation_bar.community_timeline": "本機時間軸",
|
||||
"navigation_bar.compose": "撰寫新嘟文",
|
||||
"navigation_bar.direct": "私訊",
|
||||
"navigation_bar.discover": "探索",
|
||||
"navigation_bar.domain_blocks": "隱藏的網域",
|
||||
"navigation_bar.edit_profile": "編輯個人資料",
|
||||
"navigation_bar.favourites": "最愛內容",
|
||||
"navigation_bar.favourites": "收藏",
|
||||
"navigation_bar.filters": "靜音詞彙",
|
||||
"navigation_bar.follow_requests": "關注請求",
|
||||
"navigation_bar.follows_and_followers": "Follows and followers",
|
||||
|
@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) {
|
||||
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
||||
title: action.title,
|
||||
message: action.message,
|
||||
message_values: action.message_values,
|
||||
}));
|
||||
case ALERT_DISMISS:
|
||||
return state.filterNot(item => item.get('key') === action.alert.key);
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
COMPOSE_SUGGESTIONS_CLEAR,
|
||||
COMPOSE_SUGGESTIONS_READY,
|
||||
COMPOSE_SUGGESTION_SELECT,
|
||||
COMPOSE_SUGGESTION_TAGS_UPDATE,
|
||||
COMPOSE_TAG_HISTORY_UPDATE,
|
||||
COMPOSE_SENSITIVITY_CHANGE,
|
||||
COMPOSE_SPOILERNESS_CHANGE,
|
||||
@ -205,16 +206,36 @@ const expiresInFromExpiresAt = expires_at => {
|
||||
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
|
||||
};
|
||||
|
||||
const normalizeSuggestions = (state, { accounts, emojis, tags }) => {
|
||||
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
|
||||
prefix = prefix.toLowerCase();
|
||||
if (suggestions.length < 4) {
|
||||
const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
|
||||
return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
|
||||
} else {
|
||||
return suggestions;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {
|
||||
if (accounts) {
|
||||
return accounts.map(item => ({ id: item.id, type: 'account' }));
|
||||
} else if (emojis) {
|
||||
return emojis.map(item => ({ ...item, type: 'emoji' }));
|
||||
} else {
|
||||
return sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' })));
|
||||
return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateSuggestionTags = (state, token) => {
|
||||
const prefix = token.slice(1);
|
||||
|
||||
const suggestions = state.get('suggestions').toJS();
|
||||
return state.merge({
|
||||
suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))),
|
||||
suggestion_token: token,
|
||||
});
|
||||
};
|
||||
|
||||
export default function compose(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case STORE_HYDRATE:
|
||||
@ -328,6 +349,8 @@ export default function compose(state = initialState, action) {
|
||||
return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
|
||||
case COMPOSE_SUGGESTION_SELECT:
|
||||
return insertSuggestion(state, action.position, action.token, action.completion, action.path);
|
||||
case COMPOSE_SUGGESTION_TAGS_UPDATE:
|
||||
return updateSuggestionTags(state, action.token);
|
||||
case COMPOSE_TAG_HISTORY_UPDATE:
|
||||
return state.set('tagHistory', fromJS(action.tags));
|
||||
case TIMELINE_DELETE:
|
||||
|
@ -20,6 +20,14 @@ import {
|
||||
MUTES_FETCH_SUCCESS,
|
||||
MUTES_EXPAND_SUCCESS,
|
||||
} from '../actions/mutes';
|
||||
import {
|
||||
DIRECTORY_FETCH_REQUEST,
|
||||
DIRECTORY_FETCH_SUCCESS,
|
||||
DIRECTORY_FETCH_FAIL,
|
||||
DIRECTORY_EXPAND_REQUEST,
|
||||
DIRECTORY_EXPAND_SUCCESS,
|
||||
DIRECTORY_EXPAND_FAIL,
|
||||
} from 'mastodon/actions/directory';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
@ -74,6 +82,16 @@ export default function userLists(state = initialState, action) {
|
||||
return state.setIn(['mutes', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
|
||||
case MUTES_EXPAND_SUCCESS:
|
||||
return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
|
||||
case DIRECTORY_FETCH_SUCCESS:
|
||||
return state.setIn(['directory', 'items'], ImmutableList(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
|
||||
case DIRECTORY_EXPAND_SUCCESS:
|
||||
return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false);
|
||||
case DIRECTORY_FETCH_REQUEST:
|
||||
case DIRECTORY_EXPAND_REQUEST:
|
||||
return state.setIn(['directory', 'isLoading'], true);
|
||||
case DIRECTORY_FETCH_FAIL:
|
||||
case DIRECTORY_EXPAND_FAIL:
|
||||
return state.setIn(['directory', 'isLoading'], false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -128,6 +128,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
|
||||
base.forEach(item => {
|
||||
arr.push({
|
||||
message: item.get('message'),
|
||||
message_values: item.get('message_values'),
|
||||
title: item.get('title'),
|
||||
key: item.get('key'),
|
||||
dismissAfter: 5000,
|
||||
|
33
app/javascript/mastodon/utils/log_out.js
Normal file
33
app/javascript/mastodon/utils/log_out.js
Normal file
@ -0,0 +1,33 @@
|
||||
import Rails from 'rails-ujs';
|
||||
|
||||
export const logOut = () => {
|
||||
const form = document.createElement('form');
|
||||
|
||||
const methodInput = document.createElement('input');
|
||||
methodInput.setAttribute('name', '_method');
|
||||
methodInput.setAttribute('value', 'delete');
|
||||
methodInput.setAttribute('type', 'hidden');
|
||||
form.appendChild(methodInput);
|
||||
|
||||
const csrfToken = Rails.csrfToken();
|
||||
const csrfParam = Rails.csrfParam();
|
||||
|
||||
if (csrfParam && csrfToken) {
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.setAttribute('name', csrfParam);
|
||||
csrfInput.setAttribute('value', csrfToken);
|
||||
csrfInput.setAttribute('type', 'hidden');
|
||||
form.appendChild(csrfInput);
|
||||
}
|
||||
|
||||
const submitButton = document.createElement('input');
|
||||
submitButton.setAttribute('type', 'submit');
|
||||
form.appendChild(submitButton);
|
||||
|
||||
form.method = 'post';
|
||||
form.action = '/auth/sign_out';
|
||||
form.style.display = 'none';
|
||||
|
||||
document.body.appendChild(form);
|
||||
submitButton.click();
|
||||
};
|
@ -457,6 +457,13 @@ h5 {
|
||||
.status {
|
||||
padding-bottom: 32px;
|
||||
|
||||
&--highlighted {
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
border-radius: 4px;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
td {
|
||||
font-size: 14px;
|
||||
|
@ -104,7 +104,8 @@ html {
|
||||
.box-widget input[type="email"],
|
||||
.box-widget input[type="password"],
|
||||
.box-widget textarea,
|
||||
.statuses-grid .detailed-status {
|
||||
.statuses-grid .detailed-status,
|
||||
.audio-player {
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
@ -700,3 +701,10 @@ html {
|
||||
.compose-form .compose-form__warning {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.audio-player .video-player__controls button,
|
||||
.audio-player .video-player__time-sep,
|
||||
.audio-player .video-player__time-current,
|
||||
.audio-player .video-player__time-total {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
@ -967,7 +967,8 @@
|
||||
opacity: 1;
|
||||
animation: fade 150ms linear;
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@ -1062,7 +1063,8 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
max-width: 250px;
|
||||
}
|
||||
@ -1173,7 +1175,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
@ -2108,13 +2111,23 @@ a.account__display-name {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
//.column {
|
||||
// margin-top: 0;
|
||||
.directory__list {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
|
||||
|
||||
// @media screen and (min-width: $no-gap-breakpoint) {
|
||||
// margin-top: 10px;
|
||||
// }
|
||||
//}
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.directory__card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.autosuggest-textarea__textarea {
|
||||
font-size: 16px;
|
||||
@ -2149,7 +2162,8 @@ a.account__display-name {
|
||||
padding: 15px;
|
||||
|
||||
.media-gallery,
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
@ -2191,7 +2205,8 @@ a.account__display-name {
|
||||
|
||||
.media-gallery,
|
||||
&__action-bar,
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
@ -2772,6 +2787,15 @@ a.account__display-name {
|
||||
background: $ui-base-color;
|
||||
flex: 0 1 auto;
|
||||
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: $darker-text-color;
|
||||
padding: 10px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 810px) {
|
||||
.trends__item:nth-child(3) {
|
||||
display: none;
|
||||
@ -3456,6 +3480,14 @@ a.status-card.compact:hover {
|
||||
background: rgba($base-overlay-background, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.spoiler-button__overlay__label {
|
||||
background: rgba($base-overlay-background, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4971,59 +5003,6 @@ a.status-card.compact:hover {
|
||||
}
|
||||
/* End Media Gallery */
|
||||
|
||||
/* Status Video Player */
|
||||
.status__video-player {
|
||||
background: $base-overlay-background;
|
||||
box-sizing: border-box;
|
||||
cursor: default; /* May not be needed */
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status__video-player-video {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status__video-player-expand,
|
||||
.status__video-player-mute {
|
||||
color: $primary-text-color;
|
||||
opacity: 0.8;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
|
||||
}
|
||||
|
||||
.status__video-player-spoiler {
|
||||
display: none;
|
||||
color: $primary-text-color;
|
||||
left: 4px;
|
||||
position: absolute;
|
||||
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
|
||||
top: 4px;
|
||||
z-index: 100;
|
||||
|
||||
&.status__video-player-spoiler--visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.status__video-player-expand {
|
||||
bottom: 4px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.status__video-player-mute {
|
||||
top: 4px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.detailed,
|
||||
.fullscreen {
|
||||
.video-player__volume__current,
|
||||
@ -5037,15 +5016,63 @@ a.status-card.compact:hover {
|
||||
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
background: darken($ui-base-color, 8%);
|
||||
border-radius: 4px;
|
||||
padding-bottom: 44px;
|
||||
|
||||
&.editable {
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__waveform {
|
||||
padding: 15px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-top: 1px solid lighten($ui-base-color, 4%);
|
||||
width: 100%;
|
||||
height: 0;
|
||||
left: 0;
|
||||
top: calc(50% + 1px);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-placeholder {
|
||||
background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);
|
||||
}
|
||||
|
||||
&__wave-placeholder {
|
||||
background-color: lighten($ui-base-color, 16%);
|
||||
}
|
||||
|
||||
.video-player__controls {
|
||||
padding: 0 15px;
|
||||
padding-top: 10px;
|
||||
background: darken($ui-base-color, 8%);
|
||||
border-top: 1px solid lighten($ui-base-color, 4%);
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: $base-shadow-color;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.editable {
|
||||
border-radius: 0;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@ -5328,28 +5355,137 @@ a.status-card.compact:hover {
|
||||
}
|
||||
}
|
||||
|
||||
.media-spoiler-video {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
.directory {
|
||||
&__list {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
transition: opacity 100ms ease-in;
|
||||
|
||||
.media-spoiler-video-play-icon {
|
||||
border-radius: 100px;
|
||||
color: rgba($primary-text-color, 0.8);
|
||||
font-size: 36px;
|
||||
left: 50%;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&__img {
|
||||
height: 125px;
|
||||
position: relative;
|
||||
background: darken($ui-base-color, 12%);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
padding: 10px;
|
||||
|
||||
&__name {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__relationship {
|
||||
width: 23px;
|
||||
min-height: 1px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding-top: 2px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
background: darken($ui-base-color, 8%);
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.display-name {
|
||||
margin-left: 15px;
|
||||
text-align: left;
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
color: $primary-text-color;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: $darker-text-color;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__extra {
|
||||
background: $ui-base-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.accounts-table__count {
|
||||
width: 33.33%;
|
||||
flex: 0 0 auto;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.account__header__content {
|
||||
box-sizing: border-box;
|
||||
padding: 15px 10px;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
width: 100%;
|
||||
min-height: 18px + 30px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
p {
|
||||
display: none;
|
||||
|
||||
&:first-child {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
br {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* End Video Player */
|
||||
|
||||
.account-gallery__container {
|
||||
display: flex;
|
||||
@ -5425,6 +5561,73 @@ a.status-card.compact:hover {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.directory__section-headline {
|
||||
background: darken($ui-base-color, 2%);
|
||||
border-bottom-color: transparent;
|
||||
|
||||
a,
|
||||
button {
|
||||
&.active {
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-color: transparent transparent darken($ui-base-color, 7%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
background: $ui-base-color;
|
||||
|
||||
&__column {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.radio-button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-button {
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 6px 0;
|
||||
line-height: 18px;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
input[type=radio],
|
||||
input[type=checkbox] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
border: 1px solid $ui-primary-color;
|
||||
box-sizing: border-box;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 10px;
|
||||
top: -1px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
|
||||
&.checked {
|
||||
border-color: lighten($ui-highlight-color, 8%);
|
||||
background: lighten($ui-highlight-color, 8%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
|
@ -763,6 +763,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.directory__list {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: minmax(0, 50%) minmax(0, 50%);
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.directory__card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -70,7 +70,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
|
||||
end
|
||||
|
||||
def delete_now!
|
||||
RemoveStatusService.new.call(@status)
|
||||
RemoveStatusService.new.call(@status, redraft: false)
|
||||
end
|
||||
|
||||
def payload
|
||||
|
@ -20,6 +20,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
|
||||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
}.freeze
|
||||
|
||||
def self.default_key_transform
|
||||
|
@ -5,6 +5,7 @@ class UserMailer < Devise::Mailer
|
||||
|
||||
helper :application
|
||||
helper :instance
|
||||
helper :statuses
|
||||
|
||||
add_template_helper RoutingHelper
|
||||
|
||||
@ -79,10 +80,11 @@ class UserMailer < Devise::Mailer
|
||||
end
|
||||
end
|
||||
|
||||
def warning(user, warning)
|
||||
def warning(user, warning, status_ids = nil)
|
||||
@resource = user
|
||||
@warning = warning
|
||||
@instance = Rails.configuration.x.local_domain
|
||||
@statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array)
|
||||
|
||||
I18n.with_locale(@resource.locale || I18n.default_locale) do
|
||||
mail to: @resource.email,
|
||||
|
@ -51,7 +51,6 @@
|
||||
class Account < ApplicationRecord
|
||||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
|
||||
MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
|
||||
MIN_FOLLOWERS_DISCOVERY = 10
|
||||
|
||||
include AccountAssociations
|
||||
include AccountAvatar
|
||||
@ -100,11 +99,13 @@ class Account < ApplicationRecord
|
||||
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
||||
scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
|
||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)) }
|
||||
scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
|
||||
scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
|
||||
scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) }
|
||||
scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
|
||||
scope :popular, -> { order('account_stats.followers_count desc') }
|
||||
scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
|
||||
scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
|
||||
scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
|
||||
|
||||
delegate :email,
|
||||
:unconfirmed_email,
|
||||
|
@ -19,20 +19,25 @@ class Admin::AccountAction
|
||||
:report_id,
|
||||
:warning_preset_id
|
||||
|
||||
attr_reader :warning, :send_email_notification
|
||||
attr_reader :warning, :send_email_notification, :include_statuses
|
||||
|
||||
def send_email_notification=(value)
|
||||
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
|
||||
end
|
||||
|
||||
def include_statuses=(value)
|
||||
@include_statuses = ActiveModel::Type::Boolean.new.cast(value)
|
||||
end
|
||||
|
||||
def save!
|
||||
ApplicationRecord.transaction do
|
||||
process_action!
|
||||
process_warning!
|
||||
end
|
||||
|
||||
queue_email!
|
||||
process_email!
|
||||
process_reports!
|
||||
process_queue!
|
||||
end
|
||||
|
||||
def report
|
||||
@ -110,7 +115,6 @@ class Admin::AccountAction
|
||||
authorize(target_account, :suspend?)
|
||||
log_action(:suspend, target_account)
|
||||
target_account.suspend!
|
||||
queue_suspension_worker!
|
||||
end
|
||||
|
||||
def text_for_warning
|
||||
@ -121,16 +125,22 @@ class Admin::AccountAction
|
||||
Admin::SuspensionWorker.perform_async(target_account.id)
|
||||
end
|
||||
|
||||
def queue_email!
|
||||
return unless warnable?
|
||||
def process_queue!
|
||||
queue_suspension_worker! if type == 'suspend'
|
||||
end
|
||||
|
||||
UserMailer.warning(target_account.user, warning).deliver_later!
|
||||
def process_email!
|
||||
UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable?
|
||||
end
|
||||
|
||||
def warnable?
|
||||
send_email_notification && target_account.local?
|
||||
end
|
||||
|
||||
def status_ids
|
||||
@report.status_ids if @report && include_statuses
|
||||
end
|
||||
|
||||
def warning_preset
|
||||
@warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
|
||||
end
|
||||
|
@ -9,6 +9,11 @@ class Feed
|
||||
end
|
||||
|
||||
def get(limit, max_id = nil, since_id = nil, min_id = nil)
|
||||
limit = limit.to_i
|
||||
max_id = max_id.to_i if max_id.present?
|
||||
since_id = since_id.to_i if since_id.present?
|
||||
min_id = min_id.to_i if min_id.present?
|
||||
|
||||
from_redis(limit, max_id, since_id, min_id)
|
||||
end
|
||||
|
||||
|
@ -34,7 +34,8 @@ class Form::StatusBatch
|
||||
|
||||
def delete_statuses
|
||||
Status.where(id: status_ids).reorder(nil).find_each do |status|
|
||||
RemovalWorker.perform_async(status.id)
|
||||
status.discard
|
||||
RemovalWorker.perform_async(status.id, redraft: false)
|
||||
Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
|
||||
log_action :destroy, status
|
||||
end
|
||||
|
@ -28,12 +28,12 @@ class MediaAttachment < ApplicationRecord
|
||||
|
||||
IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
|
||||
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
|
||||
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp).freeze
|
||||
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
|
||||
|
||||
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
|
||||
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
|
||||
VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
|
||||
AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/3gpp).freeze
|
||||
AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze
|
||||
|
||||
BLURHASH_OPTIONS = {
|
||||
x_comp: 4,
|
||||
|
@ -6,7 +6,7 @@ class RemoteFollow
|
||||
|
||||
attr_accessor :acct, :addressable_template
|
||||
|
||||
validates :acct, presence: true
|
||||
validates :acct, presence: true, domain: { acct: true }
|
||||
|
||||
def initialize(attrs = {})
|
||||
@acct = normalize_acct(attrs[:acct])
|
||||
@ -21,7 +21,7 @@ class RemoteFollow
|
||||
end
|
||||
|
||||
def subscribe_address_for(account)
|
||||
addressable_template.expand(uri: account.local_username_and_domain).to_s
|
||||
addressable_template.expand(uri: ActivityPub::TagManager.instance.uri_for(account)).to_s
|
||||
end
|
||||
|
||||
def interact_address_for(status)
|
||||
@ -44,6 +44,8 @@ class RemoteFollow
|
||||
end
|
||||
|
||||
[username, domain].compact.join('@')
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
value
|
||||
end
|
||||
|
||||
def fetch_template!
|
||||
|
@ -43,7 +43,7 @@ class Report < ApplicationRecord
|
||||
end
|
||||
|
||||
def statuses
|
||||
Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
|
||||
Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions)
|
||||
end
|
||||
|
||||
def media_attachments
|
||||
|
@ -22,15 +22,19 @@
|
||||
# application_id :bigint(8)
|
||||
# in_reply_to_account_id :bigint(8)
|
||||
# poll_id :bigint(8)
|
||||
# deleted_at :datetime
|
||||
#
|
||||
|
||||
class Status < ApplicationRecord
|
||||
before_destroy :unlink_from_conversations
|
||||
|
||||
include Discard::Model
|
||||
include Paginable
|
||||
include Cacheable
|
||||
include StatusThreadingConcern
|
||||
|
||||
self.discard_column = :deleted_at
|
||||
|
||||
# If `override_timestamps` is set at creation time, Snowflake ID creation
|
||||
# will be based on current time instead of `created_at`
|
||||
attr_accessor :override_timestamps
|
||||
@ -73,7 +77,7 @@ class Status < ApplicationRecord
|
||||
|
||||
accepts_nested_attributes_for :poll
|
||||
|
||||
default_scope { recent }
|
||||
default_scope { recent.kept }
|
||||
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :remote, -> { where(local: false).where.not(uri: nil) }
|
||||
|
@ -114,7 +114,7 @@ class Tag < ApplicationRecord
|
||||
class << self
|
||||
def find_or_create_by_names(name_or_names)
|
||||
Array(name_or_names).map(&method(:normalize)).uniq { |str| str.mb_chars.downcase.to_s }.map do |normalized_name|
|
||||
tag = matching_name(normalized_name).first || create(name: normalized_name)
|
||||
tag = matching_name(normalized_name).first || create!(name: normalized_name)
|
||||
|
||||
yield tag if block_given?
|
||||
|
||||
|
@ -6,12 +6,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
||||
context :security
|
||||
|
||||
context_extensions :manually_approves_followers, :featured, :also_known_as,
|
||||
:moved_to, :property_value, :hashtag, :emoji, :identity_proof
|
||||
:moved_to, :property_value, :hashtag, :emoji, :identity_proof,
|
||||
:discoverable
|
||||
|
||||
attributes :id, :type, :following, :followers,
|
||||
:inbox, :outbox, :featured,
|
||||
:preferred_username, :name, :summary,
|
||||
:url, :manually_approves_followers
|
||||
:url, :manually_approves_followers,
|
||||
:discoverable
|
||||
|
||||
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
|
||||
|
||||
|
@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
||||
|
||||
attributes :id, :username, :acct, :display_name, :locked, :bot, :created_at,
|
||||
:note, :url, :avatar, :avatar_static, :header, :header_static,
|
||||
:followers_count, :following_count, :statuses_count
|
||||
:followers_count, :following_count, :statuses_count, :last_status_at
|
||||
|
||||
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
|
||||
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
||||
|
@ -83,6 +83,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||
@account.fields = property_values || {}
|
||||
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
|
||||
@account.actor_type = actor_type
|
||||
@account.discoverable = @json['discoverable'] || false
|
||||
end
|
||||
|
||||
def set_fetchable_attributes!
|
||||
|
@ -8,7 +8,7 @@ class BatchedRemoveStatusService < BaseService
|
||||
# Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones
|
||||
# Remove statuses from home feeds
|
||||
# Push delete events to streaming API for home feeds and public feeds
|
||||
# @param [Status] statuses A preferably batched array of statuses
|
||||
# @param [Enumerable<Status>] statuses A preferably batched array of statuses
|
||||
# @param [Hash] options
|
||||
# @option [Boolean] :skip_side_effects
|
||||
def call(statuses, **options)
|
||||
|
@ -4,6 +4,11 @@ class RemoveStatusService < BaseService
|
||||
include Redisable
|
||||
include Payloadable
|
||||
|
||||
# Delete a status
|
||||
# @param [Status] status
|
||||
# @param [Hash] options
|
||||
# @option [Boolean] :redraft
|
||||
# @options [Boolean] :original_removed
|
||||
def call(status, **options)
|
||||
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
|
||||
@status = status
|
||||
@ -24,6 +29,7 @@ class RemoveStatusService < BaseService
|
||||
remove_from_public
|
||||
remove_from_media if status.media_attachments.any?
|
||||
remove_from_spam_check
|
||||
remove_media
|
||||
|
||||
@status.destroy!
|
||||
else
|
||||
@ -143,6 +149,12 @@ class RemoveStatusService < BaseService
|
||||
redis.publish('timeline:public:local:media', @payload) if @status.local?
|
||||
end
|
||||
|
||||
def remove_media
|
||||
return if @options[:redraft]
|
||||
|
||||
@status.media_attachments.destroy_all
|
||||
end
|
||||
|
||||
def remove_from_spam_check
|
||||
redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id)
|
||||
end
|
||||
|
@ -4,14 +4,22 @@ class DomainValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
return if value.blank?
|
||||
|
||||
record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(value)
|
||||
domain = begin
|
||||
if options[:acct]
|
||||
value.split('@').last
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(domain)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compliant?(value)
|
||||
Addressable::URI.new.tap { |uri| uri.host = value }
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||
false
|
||||
end
|
||||
end
|
||||
|
@ -14,6 +14,7 @@ class EmailMxValidator < ActiveModel::Validator
|
||||
|
||||
return true if domain.nil?
|
||||
|
||||
domain = TagManager.instance.normalize_domain(domain)
|
||||
hostnames = []
|
||||
ips = []
|
||||
|
||||
@ -29,6 +30,8 @@ class EmailMxValidator < ActiveModel::Validator
|
||||
end
|
||||
|
||||
ips.empty? || on_blacklist?(hostnames + ips)
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
true
|
||||
end
|
||||
|
||||
def on_blacklist?(values)
|
||||
|
@ -13,6 +13,10 @@
|
||||
.fields-group
|
||||
= f.input :send_email_notification, as: :boolean, wrapper: :with_label
|
||||
|
||||
- if params[:report_id].present?
|
||||
.fields-group
|
||||
= f.input :include_statuses, as: :boolean, wrapper: :with_label
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
- unless @warning_presets.empty?
|
||||
|
@ -103,7 +103,7 @@
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_mode)
|
||||
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
|
||||
%li
|
||||
= feature_hint('LDAP', @ldap_enabled)
|
||||
%li
|
||||
|
@ -16,11 +16,14 @@
|
||||
- video = status.proper.media_attachments.first
|
||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description
|
||||
- else
|
||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
|
||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
|
||||
|
||||
.detailed-status__meta
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do
|
||||
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
||||
- if status.discarded?
|
||||
·
|
||||
%span.negative-hint= t('admin.statuses.deleted')
|
||||
·
|
||||
- if status.reblog?
|
||||
= fa_icon('retweet fw')
|
||||
|
@ -38,8 +38,10 @@
|
||||
.table-wrapper
|
||||
%table.table
|
||||
%tbody
|
||||
- total = @usage_by_domain.sum(&:statuses_count).to_f
|
||||
|
||||
- @usage_by_domain.each do |(domain, count)|
|
||||
%tr
|
||||
%th= domain || site_hostname
|
||||
%td= number_to_percentage((count / @tag.history[0][:uses].to_f) * 100)
|
||||
%td= number_to_percentage((count / total) * 100, precision: 1)
|
||||
%td= number_with_delimiter count
|
||||
|
@ -9,7 +9,7 @@
|
||||
= image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
|
||||
|
||||
.display-name
|
||||
%span{id: "default_account_display_name", style: "display:none;"}= account.username
|
||||
%span{ id: "default_account_display_name", style: "display: none" }= account.username
|
||||
%bdi
|
||||
%strong.emojify.p-name= display_name(account, custom_emojify: true)
|
||||
%span
|
||||
|
@ -14,58 +14,43 @@
|
||||
%h1= t('directories.explore_mastodon', title: site_title)
|
||||
%p= t('directories.explanation')
|
||||
|
||||
.grid
|
||||
.column-0
|
||||
- if @accounts.empty?
|
||||
- if @accounts.empty?
|
||||
= nothing_here
|
||||
- else
|
||||
.directory
|
||||
%table.accounts-table
|
||||
%tbody
|
||||
- else
|
||||
.directory__list
|
||||
- @accounts.each do |account|
|
||||
%tr
|
||||
%td= account_link_to account
|
||||
%td.accounts-table__count.optional
|
||||
.directory__card
|
||||
.directory__card__img
|
||||
= image_tag account.header.url, alt: ''
|
||||
.directory__card__bar
|
||||
= link_to TagManager.instance.url_for(account), class: 'directory__card__bar__name' do
|
||||
.avatar
|
||||
= image_tag account.avatar.url, alt: '', width: 48, height: 48, class: 'u-photo'
|
||||
|
||||
.display-name
|
||||
%span{ id: "default_account_display_name", style: "display: none" }= account.username
|
||||
%bdi
|
||||
%strong.emojify.p-name= display_name(account, custom_emojify: true)
|
||||
%span= acct(account)
|
||||
.directory__card__bar__relationship.account__relationship
|
||||
= minimal_account_action_button(account)
|
||||
|
||||
.directory__card__extra
|
||||
.account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true)
|
||||
|
||||
.directory__card__extra
|
||||
.accounts-table__count
|
||||
= number_to_human account.statuses_count, strip_insignificant_zeros: true
|
||||
%small= t('accounts.posts', count: account.statuses_count).downcase
|
||||
%td.accounts-table__count.optional
|
||||
.accounts-table__count
|
||||
= number_to_human account.followers_count, strip_insignificant_zeros: true
|
||||
%small= t('accounts.followers', count: account.followers_count).downcase
|
||||
%td.accounts-table__count
|
||||
.accounts-table__count
|
||||
- if account.last_status_at.present?
|
||||
%time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
|
||||
- else
|
||||
\-
|
||||
= t('invites.expires_in_prompt')
|
||||
|
||||
%small= t('accounts.last_active')
|
||||
|
||||
= paginate @accounts
|
||||
|
||||
.column-1
|
||||
- if user_signed_in?
|
||||
.box-widget.notice-widget
|
||||
- if current_account.discoverable?
|
||||
- if current_account.followers_count < Account::MIN_FOLLOWERS_DISCOVERY
|
||||
%p= t('directories.enabled_but_waiting', min_followers: Account::MIN_FOLLOWERS_DISCOVERY)
|
||||
- else
|
||||
%p= t('directories.enabled')
|
||||
- else
|
||||
%p= t('directories.how_to_enable')
|
||||
|
||||
= link_to settings_profile_path do
|
||||
= t('settings.edit_profile')
|
||||
= fa_icon 'chevron-right fw'
|
||||
|
||||
- if @tags.empty? && !user_signed_in?
|
||||
.nothing-here
|
||||
- else
|
||||
- @tags.each do |tag|
|
||||
.directory__tag{ class: tag.id == @tag&.id ? 'active' : nil }
|
||||
= link_to explore_hashtag_path(tag) do
|
||||
%h4
|
||||
= fa_icon 'hashtag'
|
||||
= tag.name
|
||||
%small= t('directories.people', count: tag.accounts_count)
|
||||
|
||||
.avatar-stack
|
||||
- tag.cached_sample_accounts.each do |account|
|
||||
= image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'
|
||||
|
5
app/views/errors/400.html.haml
Normal file
5
app/views/errors/400.html.haml
Normal file
@ -0,0 +1,5 @@
|
||||
- content_for :page_title do
|
||||
= t('errors.400')
|
||||
|
||||
- content_for :content do
|
||||
= t('errors.400')
|
5
app/views/errors/406.html.haml
Normal file
5
app/views/errors/406.html.haml
Normal file
@ -0,0 +1,5 @@
|
||||
- content_for :page_title do
|
||||
= t('errors.406')
|
||||
|
||||
- content_for :content do
|
||||
= t('errors.406')
|
5
app/views/errors/503.html.haml
Normal file
5
app/views/errors/503.html.haml
Normal file
@ -0,0 +1,5 @@
|
||||
- content_for :page_title do
|
||||
= t('errors.503')
|
||||
|
||||
- content_for :content do
|
||||
= t('errors.503')
|
@ -1,4 +1,5 @@
|
||||
- i ||= 0
|
||||
- highlighted ||= false
|
||||
|
||||
%table.email-table{ cellspacing: 0, cellpadding: 0, dir: 'ltr' }
|
||||
%tbody
|
||||
@ -14,7 +15,7 @@
|
||||
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.column-cell.padded.status
|
||||
%td.column-cell.padded.status{ class: highlighted ? 'status--highlighted' : '' }
|
||||
%table.status-header{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
@ -32,5 +33,10 @@
|
||||
%div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
|
||||
= Formatter.instance.format(status)
|
||||
|
||||
- if status.media_attachments.size > 0
|
||||
%p
|
||||
- status.media_attachments.each do |a|
|
||||
= link_to medium_url(a), medium_url(a)
|
||||
|
||||
%p.status-footer
|
||||
= link_to l(status.created_at), web_url("statuses/#{status.id}")
|
||||
|
@ -28,7 +28,7 @@
|
||||
|
||||
- if Setting.profile_directory
|
||||
.fields-group
|
||||
= f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable_html', min_followers: Account::MIN_FOLLOWERS_DISCOVERY, path: explore_path), recommended: true
|
||||
= f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
|
@ -27,10 +27,14 @@
|
||||
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.audio_or_video?
|
||||
- if status.media_attachments.first.video?
|
||||
- video = status.media_attachments.first
|
||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), height: 130, alt: audio.description, preload: true, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user