This commit is contained in:
Baptiste Lemoine 2020-07-09 10:49:02 +02:00
commit ef0f131f1b
509 changed files with 14609 additions and 5400 deletions

View File

@ -1,28 +0,0 @@
version: 1
update_configs:
- package_manager: "ruby:bundler"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security
- package_manager: "javascript"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security

View File

@ -1,262 +1,60 @@
# Service dependencies
# You may set REDIS_URL instead for more advanced options
# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers
REDIS_HOST=redis
REDIS_PORT=6379
# You may set DATABASE_URL instead for more advanced options
DB_HOST=db
DB_USER=postgres
DB_NAME=postgres
DB_PASS=
DB_PORT=5432
# Optional ElasticSearch configuration
# You may also set ES_PREFIX to share the same cluster between multiple Mastodon servers (falls back to REDIS_NAMESPACE if not set)
# ES_ENABLED=true
# ES_HOST=es
# ES_PORT=9200
# This is a sample configuration file. You can generate your configuration
# with the `rake mastodon:setup` interactive setup wizard, but to customize
# your setup even further, you'll need to edit it manually. This sample does
# not demonstrate all available configuration options. Please look at
# https://docs.joinmastodon/admin/config/ for the full documentation.
# Federation
# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation.
# LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com.
# ----------
# This identifies your server and cannot be changed safely later
# ----------
LOCAL_DOMAIN=example.com
# Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links)
# Redis
# -----
REDIS_HOST=localhost
REDIS_PORT=6379
# Use this only if you need to run mastodon on a different domain than the one used for federation.
# You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md
# DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING.
# WEB_DOMAIN=mastodon.example.com
# PostgreSQL
# ----------
DB_HOST=/var/run/postgresql
DB_USER=mastodon
DB_NAME=mastodon_production
DB_PASS=
DB_PORT=5432
# Use this if you want to have several aliases handler@example1.com
# handler@example2.com etc. for the same user. LOCAL_DOMAIN should not
# be added. Comma separated values
# ALTERNATE_DOMAINS=example1.com,example2.com
# ElasticSearch (optional)
# ------------------------
ES_ENABLED=true
ES_HOST=localhost
ES_PORT=9200
# Application secrets
# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose)
# Secrets
# -------
# Make sure to use `rake secret` to generate secrets
# -------
SECRET_KEY_BASE=
OTP_SECRET=
# VAPID keys (used for push notifications
# You can generate the keys using the following command (first is the private key, second is the public one)
# You should only generate this once per instance. If you later decide to change it, all push subscription will
# be invalidated, requiring the users to access the website again to resubscribe.
#
# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web bundle exec rake mastodon:webpush:generate_vapid_key` if you use docker compose)
#
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
# Web Push
# --------
# Generate with `rake mastodon:webpush:generate_vapid_key`
# --------
VAPID_PRIVATE_KEY=
VAPID_PUBLIC_KEY=
# Registrations
# Single user mode will disable registrations and redirect frontpage to the first profile
# SINGLE_USER_MODE=true
# Prevent registrations with following e-mail domains
# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc
# Only allow registrations with the following e-mail domains
# EMAIL_DOMAIN_WHITELIST=example1.com|example2.de|etc
# Optionally change default language
# DEFAULT_LOCALE=de
# E-mail configuration
# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
# If you want to use an SMTP server without authentication (e.g local Postfix relay)
# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and
# *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough).
# Sending mail
# ------------
SMTP_SERVER=smtp.mailgun.org
SMTP_PORT=587
SMTP_LOGIN=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=notifications@example.com
#SMTP_REPLY_TO=
#SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
#SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
#SMTP_AUTH_METHOD=plain
#SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt
#SMTP_OPENSSL_VERIFY_MODE=peer
#SMTP_ENABLE_STARTTLS_AUTO=true
#SMTP_TLS=true
SMTP_FROM_ADDRESS=notificatons@example.com
# Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files.
# PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system
# PAPERCLIP_ROOT_URL=/system
# Optional asset host for multi-server setups
# The asset host must allow cross origin request from WEB_DOMAIN or LOCAL_DOMAIN
# if WEB_DOMAIN is not set. For example, the server may have the
# following header field:
# Access-Control-Allow-Origin: https://example.com/
# CDN_HOST=https://assets.example.com
# S3 (optional)
# The attachment host must allow cross origin request from WEB_DOMAIN or
# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the
# following header field:
# Access-Control-Allow-Origin: https://192.168.1.123:9000/
# S3_ENABLED=true
# S3_BUCKET=
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# S3_REGION=
# S3_PROTOCOL=http
# S3_HOSTNAME=192.168.1.123:9000
# S3 (Minio Config (optional) Please check Minio instance for details)
# The attachment host must allow cross origin request - see the description
# above.
# S3_ENABLED=true
# S3_BUCKET=
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# S3_REGION=
# S3_PROTOCOL=https
# S3_HOSTNAME=
# S3_ENDPOINT=
# S3_SIGNATURE_VERSION=
# Google Cloud Storage (optional)
# Use S3 compatible API. Since GCS does not support Multipart Upload,
# increase the value of S3_MULTIPART_THRESHOLD to disable Multipart Upload.
# The attachment host must allow cross origin request - see the description
# above.
# S3_ENABLED=true
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# S3_REGION=
# S3_PROTOCOL=https
# S3_HOSTNAME=storage.googleapis.com
# S3_ENDPOINT=https://storage.googleapis.com
# S3_MULTIPART_THRESHOLD=52428801 # 50.megabytes
# Swift (optional)
# The attachment host must allow cross origin request - see the description
# above.
# SWIFT_ENABLED=true
# SWIFT_USERNAME=
# For Keystone V3, the value for SWIFT_TENANT should be the project name
# SWIFT_TENANT=
# SWIFT_PASSWORD=
# Some OpenStack V3 providers require PROJECT_ID (optional)
# SWIFT_PROJECT_ID=
# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid
# issues with token rate-limiting during high load.
# SWIFT_AUTH_URL=
# SWIFT_CONTAINER=
# SWIFT_OBJECT_URL=
# SWIFT_REGION=
# Defaults to 'default'
# SWIFT_DOMAIN_NAME=
# Defaults to 60 seconds. Set to 0 to disable
# SWIFT_CACHE_TTL=
# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare)
# S3_ALIAS_HOST=
# Streaming API integration
# STREAMING_API_BASE_URL=
# Advanced settings
# If you need to use pgBouncer, you need to disable prepared statements:
# PREPARED_STATEMENTS=false
# Cluster number setting for streaming API server.
# If you comment out following line, cluster number will be `numOfCpuCores - 1`.
STREAMING_CLUSTER_NUM=1
# Docker mastodon user
# If you use Docker, you may want to assign UID/GID manually.
# UID=1000
# GID=1000
# LDAP authentication (optional)
# LDAP_ENABLED=true
# LDAP_HOST=localhost
# LDAP_PORT=389
# LDAP_METHOD=simple_tls
# LDAP_BASE=
# LDAP_BIND_DN=
# LDAP_PASSWORD=
# LDAP_UID=cn
# LDAP_MAIL=mail
# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email}))
# LDAP_UID_CONVERSION_ENABLED=true
# LDAP_UID_CONVERSION_SEARCH=., -
# LDAP_UID_CONVERSION_REPLACE=_
# PAM authentication (optional)
# PAM authentication uses for the email generation the "email" pam variable
# and optional as fallback PAM_DEFAULT_SUFFIX
# The pam environment variable "email" is provided by:
# https://github.com/devkral/pam_email_extractor
# PAM_ENABLED=true
# Fallback email domain for email address generation (LOCAL_DOMAIN by default)
# PAM_EMAIL_DOMAIN=example.com
# Name of the pam service (pam "auth" section is evaluated)
# PAM_DEFAULT_SERVICE=rpam
# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default)
# PAM_CONTROLLED_SERVICE=rpam
# Global OAuth settings (optional) :
# If you have only one strategy, you may want to enable this
# OAUTH_REDIRECT_AT_SIGN_IN=true
# Optional CAS authentication (cf. omniauth-cas) :
# CAS_ENABLED=true
# CAS_URL=https://sso.myserver.com/
# CAS_HOST=sso.myserver.com/
# CAS_PORT=443
# CAS_SSL=true
# CAS_VALIDATE_URL=
# CAS_CALLBACK_URL=
# CAS_LOGOUT_URL=
# CAS_LOGIN_URL=
# CAS_UID_FIELD='user'
# CAS_CA_PATH=
# CAS_DISABLE_SSL_VERIFICATION=false
# CAS_UID_KEY='user'
# CAS_NAME_KEY='name'
# CAS_EMAIL_KEY='email'
# CAS_NICKNAME_KEY='nickname'
# CAS_FIRST_NAME_KEY='firstname'
# CAS_LAST_NAME_KEY='lastname'
# CAS_LOCATION_KEY='location'
# CAS_IMAGE_KEY='image'
# CAS_PHONE_KEY='phone'
# Optional SAML authentication (cf. omniauth-saml)
# SAML_ENABLED=true
# SAML_ACS_URL=http://localhost:3000/auth/auth/saml/callback
# SAML_ISSUER=https://example.com
# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO
# SAML_IDP_CERT=
# SAML_IDP_CERT_FINGERPRINT=
# SAML_NAME_IDENTIFIER_FORMAT=
# SAML_CERT=
# SAML_PRIVATE_KEY=
# SAML_SECURITY_WANT_ASSERTION_SIGNED=true
# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true
# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1"
# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.16.840.1.113730.3.1.241"
# SAML_ATTRIBUTES_STATEMENTS_FIRST_NAME="urn:oid:2.5.4.42"
# SAML_ATTRIBUTES_STATEMENTS_LAST_NAME="urn:oid:2.5.4.4"
# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=
# Use HTTP proxy for outgoing request (optional)
# http_proxy=http://gateway.local:8118
# Access control for hidden service.
# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true
# Authorized fetch mode (optional)
# Require remote servers to authentify when fetching toots, see
# https://docs.joinmastodon.org/admin/config/#authorized_fetch
# AUTHORIZED_FETCH=true
# Whitelist mode (optional)
# Only allow federation with whitelisted domains, see
# https://docs.joinmastodon.org/admin/config/#whitelist_mode
# WHITELIST_MODE=true
# File storage (optional)
# -----------------------
S3_ENABLED=true
S3_BUCKET=files.example.com
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
S3_ALIAS_HOST=files.example.com

View File

@ -1,3 +1,4 @@
VAGRANT=true
LOCAL_DOMAIN=mastodon.local
BIND=0.0.0.0
DB_HOST=/var/run/postgresql/

1
.github/FUNDING.yml vendored
View File

@ -1,2 +1,3 @@
patreon: mastodon
open_collective: mastodon
github: [Gargron]

22
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,22 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 99
allow:
- dependency-type: all
- package-ecosystem: bundler
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 99
allow:
- dependency-type: all

27
.gitignore vendored
View File

@ -17,31 +17,36 @@
/log/*
!/log/.keep
/tmp
coverage
public/system
public/assets
public/packs
public/packs-test
/coverage
/public/system
/public/assets
/public/packs
/public/packs-test
.env
.env.production
.env.development
node_modules/
build/
/node_modules/
/build/
# Ignore Vagrant files
.vagrant/
# Ignore Capistrano customizations
config/deploy/*
/config/deploy/*
# Ignore IDE files
.vscode/
.idea/
# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose
postgres
redis
elasticsearch
/postgres
/redis
/elasticsearch
# ignore Helm lockfile, dependency charts, and local values file
/chart/Chart.lock
/chart/charts/*.tgz
/chart/values.yaml
# Ignore Apple files
.DS_Store

View File

@ -1,11 +1,11 @@
FROM ubuntu:18.04 as build-dep
FROM ubuntu:20.04 as build-dep
# Use bash for the shell
SHELL ["bash", "-c"]
# Install Node v12 (LTS)
ENV NODE_VER="12.16.1"
RUN ARCH= && \
ENV NODE_VER="12.16.3"
RUN ARCH= && \
dpkgArch="$(dpkg --print-architecture)" && \
case "${dpkgArch##*-}" in \
amd64) ARCH='x64';; \
@ -74,7 +74,7 @@ RUN cd /opt/mastodon && \
bundle install -j$(nproc) && \
yarn install --pure-lockfile
FROM ubuntu:18.04
FROM ubuntu:20.04
# Copy over all the langs needed for runtime
COPY --from=build-dep /opt/node /opt/node
@ -98,8 +98,8 @@ RUN apt update && \
# Install mastodon runtime deps
RUN apt -y --no-install-recommends install \
libssl1.1 libpq5 imagemagick ffmpeg \
libicu60 libprotobuf10 libidn11 libyaml-0-2 \
file ca-certificates tzdata libreadline7 && \
libicu66 libprotobuf17 libidn11 libyaml-0-2 \
file ca-certificates tzdata libreadline8 && \
apt -y install gcc && \
ln -s /opt/mastodon /mastodon && \
gem install bundler && \

25
Gemfile
View File

@ -9,7 +9,7 @@ gem 'puma', '~> 4.3'
gem 'rails', '~> 5.2.4.3'
gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 0.20'
gem 'rack', '~> 2.2.2'
gem 'rack', '~> 2.2.3'
gem 'thwait', '~> 0.1.0'
gem 'e2mmap', '~> 0.1.0'
@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.5'
gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.66', require: false
gem 'aws-sdk-s3', '~> 1.73', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@ -48,6 +48,7 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'
gem 'color_diff', '~> 0.1'
gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.4'
gem 'ed25519', '~> 1.2'
@ -61,7 +62,7 @@ gem 'htmlentities', '~> 4.3'
gem 'http', '~> 4.4'
gem 'http_accept_language', '~> 2.1'
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true
gem 'httplog', '~> 1.4.2'
gem 'httplog', '~> 1.4.3'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
@ -80,11 +81,11 @@ gem 'rack-attack', '~> 6.3'
gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6'
gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis']
gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 1.1'
gem 'ruby-progressbar', '~> 1.10'
gem 'sanitize', '~> 5.1'
gem 'sanitize', '~> 5.2'
gem 'sidekiq', '~> 6.0'
gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 6.0'
@ -118,15 +119,15 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.32'
gem 'capybara', '~> 3.33'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.12'
gem 'faker', '~> 2.13'
gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.18', require: false
gem 'webmock', '~> 3.8'
gem 'parallel_tests', '~> 2.32'
gem 'parallel_tests', '~> 3.0'
gem 'rspec_junit_formatter', '~> 0.4'
end
@ -139,10 +140,10 @@ group :development do
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler'
gem 'rubocop', '~> 0.84', require: false
gem 'rubocop-rails', '~> 2.5', require: false
gem 'rubocop', '~> 0.86', require: false
gem 'rubocop-rails', '~> 2.6', require: false
gem 'brakeman', '~> 4.8', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'bundler-audit', '~> 0.7', require: false
gem 'capistrano', '~> 3.14'
gem 'capistrano-rails', '~> 1.5'

View File

@ -86,27 +86,27 @@ GEM
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
arel (9.0.0)
ast (2.4.0)
ast (2.4.1)
attr_encrypted (3.1.0)
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.1.0)
aws-partitions (1.322.0)
aws-sdk-core (3.96.1)
aws-partitions (1.338.0)
aws-sdk-core (3.103.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.31.0)
aws-sdk-core (~> 3, >= 3.71.0)
aws-sdk-kms (1.36.0)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.66.0)
aws-sdk-core (~> 3, >= 3.96.1)
aws-sdk-s3 (1.73.0)
aws-sdk-core (~> 3, >= 3.102.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.4)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-sigv4 (1.2.1)
aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.13)
better_errors (2.7.1)
coderay (>= 1.0.0)
@ -124,11 +124,11 @@ GEM
bullet (6.1.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.6.1)
bundler-audit (0.7.0.1)
bundler (>= 1.2.0, < 3)
thor (~> 0.18)
thor (>= 0.18, < 2)
byebug (11.1.3)
capistrano (3.14.0)
capistrano (3.14.1)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
@ -143,7 +143,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.32.2)
capybara (3.33.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -165,15 +165,16 @@ GEM
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.3)
color_diff (0.1)
concurrent-ruby (1.1.6)
connection_pool (2.2.2)
connection_pool (2.2.3)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.6)
css_parser (1.7.1)
addressable
debug_inspector (0.0.3)
devise (4.7.1)
devise (4.7.2)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@ -188,7 +189,7 @@ GEM
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.3)
diff-lcs (1.4.4)
discard (1.2.0)
activerecord (>= 4.2, < 7)
docile (1.3.2)
@ -202,13 +203,13 @@ GEM
railties (>= 3.2, < 6.1)
e2mmap (0.1.0)
ed25519 (1.2.4)
elasticsearch (7.7.0)
elasticsearch-api (= 7.7.0)
elasticsearch-transport (= 7.7.0)
elasticsearch-api (7.7.0)
elasticsearch (7.8.0)
elasticsearch-api (= 7.8.0)
elasticsearch-transport (= 7.8.0)
elasticsearch-api (7.8.0)
multi_json
elasticsearch-dsl (0.1.9)
elasticsearch-transport (7.7.0)
elasticsearch-transport (7.8.0)
faraday (~> 1)
multi_json
encryptor (3.0.0)
@ -216,9 +217,9 @@ GEM
erubi (1.9.0)
et-orbi (1.2.4)
tzinfo
excon (0.73.0)
excon (0.75.0)
fabrication (2.21.1)
faker (2.12.0)
faker (2.13.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
@ -236,7 +237,7 @@ GEM
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-openstack (0.3.7)
fog-openstack (0.3.10)
fog-core (>= 1.45, <= 2.1.0)
fog-json (>= 1.0)
ipaddress (>= 0.8)
@ -282,10 +283,10 @@ GEM
http-parser (1.2.1)
ffi-compiler (>= 1.0, < 2.0)
http_accept_language (2.1.1)
httplog (1.4.2)
httplog (1.4.3)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.8.2)
i18n (1.8.3)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.31)
activesupport (>= 4.0.2)
@ -301,7 +302,7 @@ GEM
ipaddress (0.8.3)
iso-639 (0.3.5)
jmespath (1.4.0)
json (2.3.0)
json (2.3.1)
json-canonicalization (0.2.0)
json-ld (3.1.4)
htmlentities (~> 4.3)
@ -341,7 +342,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.5.0)
loofah (2.6.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -371,7 +372,7 @@ GEM
net-ldap (0.16.2)
net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.0.2)
net-ssh (6.1.0)
nio4r (2.5.2)
nokogiri (1.10.9)
mini_portile2 (~> 2.4.0)
@ -390,9 +391,9 @@ GEM
addressable (~> 2.3)
nokogiri (~> 1.5)
omniauth (~> 1.2)
omniauth-saml (1.10.1)
omniauth-saml (1.10.2)
omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.7)
ruby-saml (~> 1.9)
orm_adapter (0.5.0)
ox (2.13.2)
paperclip (6.0.0)
@ -404,17 +405,17 @@ GEM
paperclip-av-transcoder (0.6.4)
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.19.1)
parallel_tests (2.32.0)
parallel (1.19.2)
parallel_tests (3.0.0)
parallel
parser (2.7.1.3)
ast (~> 2.4.0)
parser (2.7.1.4)
ast (~> 2.4.1)
parslet (2.0.0)
pastel (0.7.4)
equatable (~> 0.6)
tty-color (~> 0.5)
pg (1.2.3)
pghero (2.5.0)
pghero (2.5.1)
activerecord (>= 5)
pkg-config (1.4.1)
premailer (1.11.1)
@ -439,13 +440,11 @@ GEM
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.3.1)
rack (2.2.2)
rack (2.2.3)
rack-attack (6.3.1)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-protection (2.0.8.1)
rack
rack-proxy (0.6.5)
rack
rack-test (1.1.0)
@ -463,10 +462,10 @@ GEM
bundler (>= 1.3.0)
railties (= 5.2.4.3)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.4)
actionpack (>= 5.0.1.x)
actionview (>= 5.0.1.x)
activesupport (>= 5.0.1.x)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
@ -485,12 +484,12 @@ GEM
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (13.0.1)
rdf (3.1.2)
rdf (3.1.4)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0)
rdf (~> 3.1)
redis (4.1.4)
redis (4.2.1)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3)
@ -507,9 +506,9 @@ GEM
redis-actionpack (>= 5.0, < 6)
redis-activesupport (>= 5.0, < 6)
redis-store (>= 1.2, < 2)
redis-store (1.8.2)
redis-store (1.9.0)
redis (>= 4, < 5)
regexp_parser (1.7.0)
regexp_parser (1.7.1)
request_store (1.5.0)
rack (>= 1.4)
responders (3.0.1)
@ -538,42 +537,42 @@ GEM
rspec-expectations (~> 3.9)
rspec-mocks (~> 3.9)
rspec-support (~> 3.9)
rspec-sidekiq (3.0.3)
rspec-sidekiq (3.1.0)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.9.3)
rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (0.84.0)
rubocop (0.86.0)
parallel (~> 1.10)
parser (>= 2.7.0.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7)
rexml
rubocop-ast (>= 0.0.3)
rubocop-ast (>= 0.0.3, < 1.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0)
rubocop-ast (0.0.3)
rubocop-ast (0.1.0)
parser (>= 2.7.0.1)
rubocop-rails (2.5.2)
activesupport
rubocop-rails (2.6.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 0.72.0)
rubocop (>= 0.82.0)
ruby-progressbar (1.10.1)
ruby-saml (1.11.0)
nokogiri (>= 1.5.10)
rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5)
sanitize (5.1.0)
sanitize (5.2.1)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
semantic_range (2.3.0)
sidekiq (6.0.7)
sidekiq (6.1.0)
connection_pool (>= 2.2.2)
rack (~> 2.0)
rack-protection (>= 2.0.0)
redis (>= 4.1.0)
redis (>= 4.2.0)
sidekiq-bulk (0.2.0)
sidekiq
sidekiq-scheduler (3.0.1)
@ -660,7 +659,7 @@ GEM
jwt (~> 2.0)
websocket-driver (0.7.2)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4)
websocket-extensions (0.1.5)
wisper (2.0.1)
xpath (3.2.0)
nokogiri (~> 1.8)
@ -673,7 +672,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.7)
addressable (~> 2.7)
annotate (~> 3.1)
aws-sdk-s3 (~> 1.66)
aws-sdk-s3 (~> 1.73)
better_errors (~> 2.7)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
@ -681,16 +680,17 @@ DEPENDENCIES
brakeman (~> 4.8)
browser
bullet (~> 6.1)
bundler-audit (~> 0.6)
bundler-audit (~> 0.7)
capistrano (~> 3.14)
capistrano-rails (~> 1.5)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.32)
capybara (~> 3.33)
charlock_holmes (~> 0.7.7)
chewy (~> 5.1)
cld3 (~> 3.3.0)
climate_control (~> 0.2)
color_diff (~> 0.1)
concurrent-ruby
connection_pool
devise (~> 4.7)
@ -702,7 +702,7 @@ DEPENDENCIES
e2mmap (~> 0.1.0)
ed25519 (~> 1.2)
fabrication (~> 2.21)
faker (~> 2.12)
faker (~> 2.13)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@ -716,7 +716,7 @@ DEPENDENCIES
http (~> 4.4)
http_accept_language (~> 2.1)
http_parser.rb (~> 0.6)!
httplog (~> 1.4.2)
httplog (~> 1.4.3)
i18n-tasks (~> 0.9)
idn-ruby
iso-639
@ -744,7 +744,7 @@ DEPENDENCIES
paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel (~> 1.19)
parallel_tests (~> 2.32)
parallel_tests (~> 3.0)
parslet
pg (~> 1.2)
pghero (~> 2.5)
@ -756,7 +756,7 @@ DEPENDENCIES
pry-rails (~> 0.3)
puma (~> 4.3)
pundit (~> 2.1)
rack (~> 2.2.2)
rack (~> 2.2.3)
rack-attack (~> 6.3)
rack-cors (~> 1.1)
rails (~> 5.2.4.3)
@ -764,17 +764,17 @@ DEPENDENCIES
rails-i18n (~> 5.1)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.4)
redis (~> 4.1)
redis (~> 4.2)
redis-namespace (~> 1.7)
redis-rails (~> 5.0)
rqrcode (~> 1.1)
rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.0)
rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4)
rubocop (~> 0.84)
rubocop-rails (~> 2.5)
rubocop (~> 0.86)
rubocop-rails (~> 2.6)
ruby-progressbar (~> 1.10)
sanitize (~> 5.1)
sanitize (~> 5.2)
sidekiq (~> 6.0)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)

View File

@ -1,7 +1,8 @@
# frozen_string_literal: true
class AccountsController < ApplicationController
PAGE_SIZE = 20
PAGE_SIZE = 20
PAGE_SIZE_MAX = 200
include AccountControllerConcern
include SignatureAuthentication
@ -10,7 +11,7 @@ class AccountsController < ApplicationController
before_action :set_body_classes
skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
def show
respond_to do |format|
@ -40,7 +41,8 @@ class AccountsController < ApplicationController
format.rss do
expires_in 1.minute, public: true
@statuses = filtered_statuses.without_reblogs.limit(PAGE_SIZE)
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = filtered_statuses.without_reblogs.limit(limit)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end

View File

@ -33,6 +33,8 @@ module Admin
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
ensure
redirect_to admin_custom_emojis_path(filter_params)
end

View File

@ -7,7 +7,7 @@ class Api::BaseController < ApplicationController
include RateLimitHeaders
skip_before_action :store_current_location
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access?
before_action :set_cache_headers

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class Api::V1::Accounts::NotesController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
before_action :require_user!
before_action :set_account
def create
if params[:comment].blank?
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
else
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
@note.comment = params[:comment]
@note.save! if @note.changed?
end
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
end
private
def set_account
@account = Account.find(params[:account_id])
end
def relationships_presenter
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
end
end

View File

@ -39,7 +39,7 @@ class Api::V1::MediaController < Api::BaseController
end
def media_attachment_params
params.permit(:file, :description, :focus)
params.permit(:file, :thumbnail, :description, :focus)
end
def file_type_error

View File

@ -8,7 +8,10 @@ class Auth::PasswordsController < Devise::PasswordsController
def update
super do |resource|
resource.session_activations.destroy_all if resource.errors.empty?
if resource.errors.empty?
resource.session_activations.destroy_all
resource.forget_me!
end
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Auth::RegistrationsController < Devise::RegistrationsController
include Devise::Controllers::Rememberable
layout :determine_layout
before_action :set_invite, only: [:new, :create]
@ -24,7 +26,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def update
super do |resource|
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?
if resource.saved_change_to_encrypted_password?
resource.clear_other_sessions(current_session.session_id)
resource.forget_me!
remember_me(resource)
end
end
end

View File

@ -8,7 +8,8 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_no_authentication, only: [:create]
skip_before_action :require_functional!
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
include TwoFactorAuthenticationConcern
include SignInTokenAuthenticationConcern
before_action :set_instance_presenter, only: [:new]
before_action :set_body_classes
@ -39,8 +40,8 @@ class Auth::SessionsController < Devise::SessionsController
protected
def find_user
if session[:otp_user_id]
User.find(session[:otp_user_id])
if session[:attempt_user_id]
User.find(session[:attempt_user_id])
else
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
@ -49,7 +50,7 @@ class Auth::SessionsController < Devise::SessionsController
end
def user_params
params.require(:user).permit(:email, :password, :otp_attempt)
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
end
def after_sign_in_path_for(resource)
@ -70,47 +71,6 @@ class Auth::SessionsController < Devise::SessionsController
super
end
def two_factor_enabled?
find_user&.otp_required_for_login?
end
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
rescue OpenSSL::Cipher::CipherError
false
end
def authenticate_with_two_factor
user = self.resource = find_user
if user_params[:otp_attempt].present? && session[:otp_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
# If encrypted_password is blank, we got the user from LDAP or PAM,
# so credentials are already valid
prompt_for_two_factor(user)
end
end
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
session.delete(:otp_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
end
end
def prompt_for_two_factor(user)
session[:otp_user_id] = user.id
@body_classes = 'lighter'
render :two_factor
end
def require_no_authentication
super
# Delete flash message that isn't entirely useful and may be confusing in

View File

@ -7,8 +7,6 @@ module Localized
around_action :set_locale
end
private
def set_locale
locale = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
locale ||= session[:locale] ||= default_locale
@ -19,6 +17,8 @@ module Localized
end
end
private
def default_locale
if ENV['DEFAULT_LOCALE'].present?
I18n.default_locale

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
module SignInTokenAuthenticationConcern
extend ActiveSupport::Concern
included do
prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create]
end
def sign_in_token_required?
find_user&.suspicious_sign_in?(request.remote_ip)
end
def valid_sign_in_token_attempt?(user)
Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt])
end
def authenticate_with_sign_in_token
user = self.resource = find_user
if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
authenticate_with_sign_in_token_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_sign_in_token(user)
end
end
def authenticate_with_sign_in_token_attempt(user)
if valid_sign_in_token_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
prompt_for_sign_in_token(user)
end
end
def prompt_for_sign_in_token(user)
if user.sign_in_token_expired?
user.generate_sign_in_token && user.save
UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
end
set_locale do
session[:attempt_user_id] = user.id
@body_classes = 'lighter'
render :sign_in_token
end
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
module TwoFactorAuthenticationConcern
extend ActiveSupport::Concern
included do
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
end
def two_factor_enabled?
find_user&.otp_required_for_login?
end
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
rescue OpenSSL::Cipher::CipherError
false
end
def authenticate_with_two_factor
user = self.resource = find_user
if user_params[:otp_attempt].present? && session[:attempt_user_id]
authenticate_with_two_factor_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
end
def authenticate_with_two_factor_attempt(user)
if valid_otp_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
end
end
def prompt_for_two_factor(user)
set_locale do
session[:attempt_user_id] = user.id
@body_classes = 'lighter'
render :two_factor
end
end
end

View File

@ -9,7 +9,7 @@ class DirectoriesController < ApplicationController
before_action :set_tag, only: :show
before_action :set_accounts
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
def index
render :index

View File

@ -8,7 +8,7 @@ class FollowerAccountsController < ApplicationController
before_action :set_cache_headers
skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
def index
respond_to do |format|

View File

@ -8,7 +8,7 @@ class FollowingAccountsController < ApplicationController
before_action :set_cache_headers
skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
def index
respond_to do |format|

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
class HomeController < ApplicationController
before_action :redirect_unauthenticated_to_permalinks!
before_action :authenticate_user!
before_action :set_referrer_policy_header
@ -10,7 +11,7 @@ class HomeController < ApplicationController
private
def authenticate_user!
def redirect_unauthenticated_to_permalinks!
return if user_signed_in?
matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
@ -35,6 +36,7 @@ class HomeController < ApplicationController
end
matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z})
redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path)
end

View File

@ -4,7 +4,7 @@ class MediaController < ApplicationController
include Authorization
skip_before_action :store_current_location
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_media_attachment

View File

@ -2,6 +2,7 @@
class MediaProxyController < ApplicationController
include RoutingHelper
include Authorization
skip_before_action :store_current_location
skip_before_action :require_functional!
@ -10,12 +11,14 @@ class MediaProxyController < ApplicationController
rescue_from ActiveRecord::RecordInvalid, with: :not_found
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
rescue_from Mastodon::NotPermittedError, with: :not_found
rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
def show
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@media_attachment = MediaAttachment.remote.find(params[:id])
@media_attachment = MediaAttachment.remote.attached.find(params[:id])
authorize @media_attachment.status, :show?
redownload! if @media_attachment.needs_redownload? && !reject_media?
else
raise Mastodon::RaceConditionError
@ -28,8 +31,8 @@ class MediaProxyController < ApplicationController
private
def redownload!
@media_attachment.file_remote_url = @media_attachment.remote_url
@media_attachment.created_at = Time.now.utc
@media_attachment.download_file!
@media_attachment.created_at = Time.now.utc
@media_attachment.save!
end

View File

@ -10,7 +10,7 @@ class RemoteInteractionController < ApplicationController
before_action :set_status
before_action :set_body_classes
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
def new
@remote_follow = RemoteFollow.new(session_params)

View File

@ -7,13 +7,8 @@ module Settings
before_action :set_picture
def destroy
if valid_picture
account_params = {
@picture => nil,
(@picture + '_remote_url') => nil,
}
msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil
if valid_picture?
msg = I18n.t('generic.changes_saved_msg') if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
redirect_to settings_profile_path, notice: msg, status: 303
else
bad_request
@ -30,8 +25,8 @@ module Settings
@picture = params[:id]
end
def valid_picture
@picture == 'avatar' || @picture == 'header'
def valid_picture?
%w(avatar header).include?(@picture)
end
end
end

View File

@ -19,7 +19,7 @@ class StatusesController < ApplicationController
before_action :set_autoplay, only: :embed
skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: [:show, :embed]
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
content_security_policy only: :embed do |p|
p.frame_ancestors(false)

View File

@ -3,7 +3,8 @@
class TagsController < ApplicationController
include SignatureVerification
PAGE_SIZE = 20
PAGE_SIZE = 20
PAGE_SIZE_MAX = 200
layout 'public'
@ -14,7 +15,7 @@ class TagsController < ApplicationController
before_action :set_body_classes
before_action :set_instance_presenter
skip_before_action :require_functional!
skip_before_action :require_functional!, unless: :whitelist_mode?
def show
respond_to do |format|
@ -25,6 +26,7 @@ class TagsController < ApplicationController
format.rss do
expires_in 0, public: true
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE)
@statuses = cache_collection(@statuses, Status)

View File

@ -77,6 +77,18 @@ module ApplicationHelper
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
end
def visibility_icon(status)
if status.public_visibility?
fa_icon('globe', title: I18n.t('statuses.visibilities.public'))
elsif status.unlisted_visibility?
fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
elsif status.private_visibility? || status.limited_visibility?
fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
elsif status.direct_visibility?
fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
end
end
def custom_emoji_tag(custom_emoji, animate = true)
if animate
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
@ -136,6 +148,11 @@ module ApplicationHelper
text: [params[:title], params[:text], params[:url]].compact.join(' '),
}
permit_visibilities = %w(public unlisted private direct)
default_privacy = current_account&.user&.setting_default_privacy
permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present?
state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility]
if user_signed_in?
state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
state_params[:push_subscription] = current_account.user.web_push_subscription(current_session)

View File

@ -15,11 +15,13 @@ module StatusesHelper
end
def media_summary(status)
attachments = { image: 0, video: 0 }
attachments = { image: 0, video: 0, audio: 0 }
status.media_attachments.each do |media|
if media.video?
attachments[:video] += 1
elsif media.audio?
attachments[:audio] += 1
else
attachments[:image] += 1
end

View File

@ -1,5 +1,16 @@
# frozen_string_literal: true
# Monkey-patch on monkey-patch.
# Because it conflicts with the request.rb patch.
class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
def connect(socket_class, host, port, nodelay = false)
::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
@socket = socket_class.open(host, port)
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
end
end
end
module WebfingerHelper
def webfinger!(uri)
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
@ -12,6 +23,14 @@ module WebfingerHelper
headers: {
'User-Agent': Mastodon::Version.user_agent,
},
timeout_class: HTTP::Timeout::PerOperationOriginal,
timeout_options: {
write_timeout: 10,
connect_timeout: 5,
read_timeout: 10,
},
}
Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger

View File

@ -0,0 +1,37 @@
import api from '../api';
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
export function submitAccountNote(id, value) {
return (dispatch, getState) => {
dispatch(submitAccountNoteRequest());
api(getState).post(`/api/v1/accounts/${id}/note`, {
comment: value,
}).then(response => {
dispatch(submitAccountNoteSuccess(response.data));
}).catch(error => dispatch(submitAccountNoteFail(error)));
};
};
export function submitAccountNoteRequest() {
return {
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
};
};
export function submitAccountNoteSuccess(relationship) {
return {
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
relationship,
};
};
export function submitAccountNoteFail(error) {
return {
type: ACCOUNT_NOTE_SUBMIT_FAIL,
error,
};
};

View File

@ -28,6 +28,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
@ -260,6 +265,49 @@ export function uploadCompose(files) {
};
};
export const uploadThumbnail = (id, file) => (dispatch, getState) => {
dispatch(uploadThumbnailRequest());
const total = file.size;
const data = new FormData();
data.append('thumbnail', file);
api(getState).put(`/api/v1/media/${id}`, data, {
onUploadProgress: ({ loaded }) => {
dispatch(uploadThumbnailProgress(loaded, total));
},
}).then(({ data }) => {
dispatch(uploadThumbnailSuccess(data));
}).catch(error => {
dispatch(uploadThumbnailFail(id, error));
});
};
export const uploadThumbnailRequest = () => ({
type: THUMBNAIL_UPLOAD_REQUEST,
skipLoading: true,
});
export const uploadThumbnailProgress = (loaded, total) => ({
type: THUMBNAIL_UPLOAD_PROGRESS,
loaded,
total,
skipLoading: true,
});
export const uploadThumbnailSuccess = media => ({
type: THUMBNAIL_UPLOAD_SUCCESS,
media,
skipLoading: true,
});
export const uploadThumbnailFail = error => ({
type: THUMBNAIL_UPLOAD_FAIL,
error,
skipLoading: true,
});
export function changeUploadCompose(id, params) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
@ -278,6 +326,7 @@ export function changeUploadComposeRequest() {
skipLoading: true,
};
};
export function changeUploadComposeSuccess(media) {
return {
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,

View File

@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
export function searchTextFromRawStatus (status) {
const spoilerText = status.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
}

View File

@ -1,4 +1,4 @@
import { shallow } from 'enzyme';
import { render, fireEvent, screen } from '@testing-library/react';
import React from 'react';
import renderer from 'react-test-renderer';
import Button from '../button';
@ -21,16 +21,16 @@ describe('<Button />', () => {
it('handles click events using the given handler', () => {
const handler = jest.fn();
const button = shallow(<Button onClick={handler} />);
button.find('button').simulate('click');
render(<Button onClick={handler}>button</Button>);
fireEvent.click(screen.getByText('button'));
expect(handler.mock.calls.length).toEqual(1);
});
it('does not handle click events if props.disabled given', () => {
const handler = jest.fn();
const button = shallow(<Button onClick={handler} disabled />);
button.find('button').simulate('click');
render(<Button onClick={handler} disabled>button</Button>);
fireEvent.click(screen.getByText('button'));
expect(handler.mock.calls.length).toEqual(0);
});

View File

@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { shortNumberFormat } from 'mastodon/utils/numbers';
import ShortNumber from 'mastodon/components/short_number';
import { FormattedMessage } from 'react-intl';
export default class AutosuggestHashtag extends React.PureComponent {
@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent {
}).isRequired,
};
render () {
render() {
const { tag } = this.props;
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
const weeklyUses = tag.history && (
<ShortNumber
value={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>
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,62 @@
// @ts-check
import React from 'react';
import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
*
* @param {"statuses" | "following" | "followers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
* Renderer function
* @throws If counterType is not covered by this function
*/
export function counterRenderer(counterType, isBold = true) {
/**
* @type {(displayNumber: JSX.Element) => JSX.Element}
*/
const renderCounter = isBold
? (displayNumber) => <strong>{displayNumber}</strong>
: (displayNumber) => displayNumber;
switch (counterType) {
case 'statuses': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'following': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, other {{counter} Following}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'followers': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
}
}

View File

@ -1,26 +1,65 @@
// @ts-check
import React from 'react';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from './permalink';
import { shortNumberFormat } from '../utils/numbers';
import ShortNumber from 'mastodon/components/short_number';
/**
* Used to render counter of how much people are talking about hashtag
*
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
*/
const accountsCountRenderer = (displayNumber, pluralReady) => (
<FormattedMessage
id='trends.counter_by_accounts'
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
const Hashtag = ({ hashtag }) => (
<div className='trends__item'>
<div className='trends__item__name'>
<Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}>
<Permalink
href={hashtag.get('url')}
to={`/timelines/tag/${hashtag.get('name')}`}
>
#<span>{hashtag.get('name')}</span>
</Permalink>
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
<ShortNumber
value={
hashtag.getIn(['history', 0, 'accounts']) * 1 +
hashtag.getIn(['history', 1, 'accounts']) * 1
}
renderer={accountsCountRenderer}
/>
</div>
<div className='trends__item__current'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
<ShortNumber
value={
hashtag.getIn(['history', 0, 'uses']) * 1 +
hashtag.getIn(['history', 1, 'uses']) * 1
}
/>
</div>
<div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
<Sparklines
width={50}
height={28}
data={hashtag
.get('history')
.reverse()
.map((day) => day.get('uses'))
.toArray()}
>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</div>

View File

@ -8,10 +8,10 @@ import { isIOS } from '../is_mobile';
import classNames from 'classnames';
import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
import { decode } from 'blurhash';
import { debounce } from 'lodash';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible',
defaultMessage: 'Hide {number, plural, one {image} other {images}}' },
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide {number, plural, one {image} other {images}}' },
});
class Item extends React.PureComponent {
@ -267,6 +267,14 @@ class MediaGallery extends React.PureComponent {
width: this.props.defaultWidth,
};
componentDidMount () {
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
window.removeEventListener('resize', this.handleResize);
}
componentWillReceiveProps (nextProps) {
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
@ -275,6 +283,14 @@ class MediaGallery extends React.PureComponent {
}
}
handleResize = debounce(() => {
if (this.node) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handleOpen = () => {
if (this.props.onToggleVisibility) {
this.props.onToggleVisibility();
@ -287,17 +303,27 @@ class MediaGallery extends React.PureComponent {
this.props.onOpenMedia(this.props.media, index);
}
handleRef = (node) => {
if (node) {
// offsetWidth triggers a layout, so only calculate when we need to
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
handleRef = c => {
this.node = c;
this.setState({
width: node.offsetWidth,
});
if (this.node) {
this._setDimensions();
}
}
_setDimensions () {
const width = this.node.offsetWidth;
// offsetWidth triggers a layout, so only calculate when we need to
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({
width: width,
});
}
isFullSizeEligible() {
const { media } = this.props;
return media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);

View File

@ -66,7 +66,7 @@ export default class ModalRoot extends React.PureComponent {
// immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => {
this.activeElement.focus();
this.activeElement.focus({ preventScroll: true });
this.activeElement = null;
}).catch((error) => {
console.error(error);

View File

@ -32,6 +32,7 @@ export default class ScrollableList extends PureComponent {
hasMore: PropTypes.bool,
numPending: PropTypes.number,
prepend: PropTypes.node,
append: PropTypes.node,
alwaysPrepend: PropTypes.bool,
emptyMessage: PropTypes.node,
children: PropTypes.node,
@ -280,7 +281,7 @@ export default class ScrollableList extends PureComponent {
}
render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children);
@ -327,6 +328,8 @@ export default class ScrollableList extends PureComponent {
))}
{loadMore}
{!hasMore && append}
</div>
</div>
);

View File

@ -0,0 +1,117 @@
import React from 'react';
import PropTypes from 'prop-types';
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
import { FormattedMessage, FormattedNumber } from 'react-intl';
// @ts-check
/**
* @callback ShortNumberRenderer
* @param {JSX.Element} displayNumber Number to display
* @param {number} pluralReady Number used for pluralization
* @returns {JSX.Element} Final render of number
*/
/**
* @typedef {object} ShortNumberProps
* @property {number} value Number to display in short variant
* @property {ShortNumberRenderer} [renderer]
* Custom renderer for numbers, provided as a prop. If another renderer
* passed as a child of this component, this prop won't be used.
* @property {ShortNumberRenderer} [children]
* Custom renderer for numbers, provided as a child. If another renderer
* passed as a prop of this component, this one will be used instead.
*/
/**
* Component that renders short big number to a shorter version
*
* @param {ShortNumberProps} param0 Props for the component
* @returns {JSX.Element} Rendered number
*/
function ShortNumber({ value, renderer, children }) {
const shortNumber = toShortNumber(value);
const [, division] = shortNumber;
// eslint-disable-next-line eqeqeq
if (children != null && renderer != null) {
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
}
// eslint-disable-next-line eqeqeq
const customRenderer = children != null ? children : renderer;
const displayNumber = <ShortNumberCounter value={shortNumber} />;
// eslint-disable-next-line eqeqeq
return customRenderer != null
? customRenderer(displayNumber, pluralReady(value, division))
: displayNumber;
}
ShortNumber.propTypes = {
value: PropTypes.number.isRequired,
renderer: PropTypes.func,
children: PropTypes.func,
};
/**
* @typedef {object} ShortNumberCounterProps
* @property {import('../utils/number').ShortNumber} value Short number
*/
/**
* Renders short number into corresponding localizable react fragment
*
* @param {ShortNumberCounterProps} param0 Props for the component
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
*/
function ShortNumberCounter({ value }) {
const [rawNumber, unit, maxFractionDigits = 0] = value;
const count = (
<FormattedNumber
value={rawNumber}
maximumFractionDigits={maxFractionDigits}
/>
);
let values = { count, rawNumber };
switch (unit) {
case DECIMAL_UNITS.THOUSAND: {
return (
<FormattedMessage
id='units.short.thousand'
defaultMessage='{count}K'
values={values}
/>
);
}
case DECIMAL_UNITS.MILLION: {
return (
<FormattedMessage
id='units.short.million'
defaultMessage='{count}M'
values={values}
/>
);
}
case DECIMAL_UNITS.BILLION: {
return (
<FormattedMessage
id='units.short.billion'
defaultMessage='{count}B'
values={values}
/>
);
}
// Not sure if we should go farther - @Sasha-Sorokin
default: return count;
}
}
ShortNumberCounter.propTypes = {
value: PropTypes.arrayOf(PropTypes.number),
};
export default React.memo(ShortNumber);

View File

@ -10,7 +10,7 @@ import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
@ -51,6 +51,13 @@ export const defaultMediaVisibility = (status) => {
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
};
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
export default @injectIntl
class Status extends ImmutablePureComponent {
@ -345,9 +352,14 @@ class Status extends ImmutablePureComponent {
<Component
src={attachment.get('url')}
alt={attachment.get('description')}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
peaks={[0]}
height={70}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
/>
)}
</Bundle>
@ -401,6 +413,7 @@ class Status extends ImmutablePureComponent {
compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
/>
);
}
@ -413,6 +426,15 @@ class Status extends ImmutablePureComponent {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
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}>
@ -422,6 +444,7 @@ class Status extends ImmutablePureComponent {
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>

View File

@ -237,9 +237,6 @@ class StatusActionBar extends ImmutablePureComponent {
const account = status.get('account');
let menu = [];
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
@ -259,10 +256,6 @@ class StatusActionBar extends ImmutablePureComponent {
if (status.getIn(['account', 'id']) === me) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
} else {
if (status.get('visibility') === 'private') {
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
}
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
@ -305,12 +298,8 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
let replyIcon;
let replyTitle;
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
@ -319,6 +308,19 @@ class StatusActionBar extends ImmutablePureComponent {
replyTitle = intl.formatMessage(messages.replyAll);
}
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let reblogTitle = '';
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
const shareButton = ('share' in navigator) && publicStatus && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
@ -326,7 +328,7 @@ class StatusActionBar extends ImmutablePureComponent {
return (
<div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}

View File

@ -0,0 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
const TimelineHint = ({ resource, url }) => (
<div className='timeline-hint'>
<strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
<br />
<a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
</div>
);
TimelineHint.propTypes = {
resource: PropTypes.node.isRequired,
url: PropTypes.string.isRequired,
};
export default TimelineHint;

View File

@ -0,0 +1,170 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import { is } from 'immutable';
const messages = defineMessages({
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
});
class InlineAlert extends React.PureComponent {
static propTypes = {
show: PropTypes.bool,
};
state = {
mountMessage: false,
};
static TRANSITION_DELAY = 200;
componentWillReceiveProps (nextProps) {
if (!this.props.show && nextProps.show) {
this.setState({ mountMessage: true });
} else if (this.props.show && !nextProps.show) {
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
}
}
render () {
const { show } = this.props;
const { mountMessage } = this.state;
return (
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
</span>
);
}
}
export default @injectIntl
class AccountNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
value: PropTypes.string,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
value: null,
saving: false,
saved: false,
};
componentWillMount () {
this._reset();
}
componentWillReceiveProps (nextProps) {
const accountWillChange = !is(this.props.account, nextProps.account);
const newState = {};
if (accountWillChange && this._isDirty()) {
this._save(false);
}
if (accountWillChange || nextProps.value === this.state.value) {
newState.saving = false;
}
if (this.props.value !== nextProps.value) {
newState.value = nextProps.value;
}
this.setState(newState);
}
componentWillUnmount () {
if (this._isDirty()) {
this._save(false);
}
}
setTextareaRef = c => {
this.textarea = c;
}
handleChange = e => {
this.setState({ value: e.target.value, saving: false });
};
handleKeyDown = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this._save();
if (this.textarea) {
this.textarea.blur();
}
} else if (e.keyCode === 27) {
e.preventDefault();
this._reset(() => {
if (this.textarea) {
this.textarea.blur();
}
});
}
}
handleBlur = () => {
if (this._isDirty()) {
this._save();
}
}
_save (showMessage = true) {
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
if (showMessage) {
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
}
}
_reset (callback) {
this.setState({ value: this.props.value }, callback);
}
_isDirty () {
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
}
render () {
const { account, intl } = this.props;
const { value, saved } = this.state;
if (!account) {
return null;
}
return (
<div className='account__header__account-note'>
<label htmlFor={`account-note-${account.get('id')}`}>
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
</label>
<Textarea
id={`account-note-${account.get('id')}`}
className='account__header__account-note__content'
disabled={this.props.value === null || value === null}
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleBlur}
ref={this.setTextareaRef}
/>
</div>
);
}
}

View File

@ -8,9 +8,11 @@ import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import Avatar from 'mastodon/components/avatar';
import { shortNumberFormat } from 'mastodon/utils/numbers';
import { counterRenderer } from 'mastodon/components/common_counter';
import ShortNumber from 'mastodon/components/short_number';
import { NavLink } from 'react-router-dom';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import AccountNoteContainer from '../containers/account_note_container';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -312,20 +314,31 @@ class Header extends ImmutablePureComponent {
</div>
)}
{account.get('id') !== me && <AccountNoteContainer account={account} />}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
</div>
<div className='account__header__extra__links'>
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong> <FormattedMessage id='account.posts' defaultMessage='Toots' />
<ShortNumber
value={account.get('statuses_count')}
renderer={counterRenderer('statuses')}
/>
</NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<strong>{shortNumberFormat(account.get('following_count'))}</strong> <FormattedMessage id='account.follows' defaultMessage='Follows' />
<ShortNumber
value={account.get('following_count')}
renderer={counterRenderer('following')}
/>
</NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<strong>{shortNumberFormat(account.get('followers_count'))}</strong> <FormattedMessage id='account.followers' defaultMessage='Followers' />
<ShortNumber
value={account.get('followers_count')}
renderer={counterRenderer('followers')}
/>
</NavLink>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { submitAccountNote } from 'mastodon/actions/account_notes';
import AccountNote from '../components/account_note';
const mapStateToProps = (state, { account }) => ({
value: account.getIn(['relationship', 'note']),
});
const mapDispatchToProps = (dispatch, { account }) => ({
onSave (value) {
dispatch(submitAccountNote(account.get('id'), value));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);

View File

@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
};
@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onAddToList(this.props.account);
}
handleEditAccountNote = () => {
this.props.onEditAccountNote(this.props.account);
}
render () {
const { account, hideTabs, identity_proofs } = this.props;
@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
onUnblockDomain={this.handleUnblockDomain}
onEndorseToggle={this.handleEndorseToggle}
onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote}
domain={this.props.domain}
/>

View File

@ -14,6 +14,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
import MissingIndicator from 'mastodon/components/missing_indicator';
import TimelineHint from 'mastodon/components/timeline_hint';
const emptyList = ImmutableList();
@ -21,6 +22,8 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
const path = withReplies ? `${accountId}:with_replies` : accountId;
return {
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
@ -30,6 +33,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
};
};
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} />
);
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
};
export default @connect(mapStateToProps)
class AccountTimeline extends ImmutablePureComponent {
@ -44,6 +55,8 @@ class AccountTimeline extends ImmutablePureComponent {
withReplies: PropTypes.bool,
blockedBy: PropTypes.bool,
isAccount: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
@ -78,7 +91,7 @@ class AccountTimeline extends ImmutablePureComponent {
}
render () {
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn } = this.props;
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) {
return (
@ -97,7 +110,17 @@ class AccountTimeline extends ImmutablePureComponent {
);
}
const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
let emptyMessage;
if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
return (
<Column>
@ -106,6 +129,7 @@ class AccountTimeline extends ImmutablePureComponent {
<StatusList
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
alwaysPrepend
append={remoteMessage}
scrollKey='account_timeline'
statusIds={blockedBy ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds}

View File

@ -1,11 +1,17 @@
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';
import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
import { debounce } from 'lodash';
const hex2rgba = (hex, alpha = 1) => {
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
@ -15,131 +21,151 @@ const messages = defineMessages({
download: { id: 'video.download', defaultMessage: 'Download file' },
});
const TICK_SIZE = 10;
const PADDING = 180;
export default @injectIntl
class Audio extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
poster: PropTypes.string,
duration: PropTypes.number,
peaks: PropTypes.arrayOf(PropTypes.number),
width: PropTypes.number,
height: PropTypes.number,
preload: PropTypes.bool,
editable: PropTypes.bool,
fullscreen: PropTypes.bool,
intl: PropTypes.object.isRequired,
cacheWidth: PropTypes.func,
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
accentColor: PropTypes.string,
};
state = {
width: this.props.width,
currentTime: 0,
buffer: 0,
duration: null,
paused: true,
muted: false,
volume: 0.5,
dragging: false,
};
// Hard coded in components.scss
// Any way to get ::before values programatically?
volWidth = 50;
volOffset = 70;
setPlayerRef = c => {
this.player = c;
volHandleOffset = v => {
const offset = v * this.volWidth + this.volOffset;
if (this.player) {
this._setDimensions();
}
}
return (offset > 110) ? 110 : offset;
_setDimensions () {
const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({ width, height });
}
setSeekRef = c => {
this.seek = c;
}
setVolumeRef = c => {
this.volume = c;
}
setWaveformRef = c => {
this.waveform = c;
setAudioRef = c => {
this.audio = c;
if (this.audio) {
this.setState({ volume: this.audio.volume, muted: this.audio.muted });
}
}
setCanvasRef = c => {
this.canvas = c;
if (c) {
this.canvasContext = c.getContext('2d');
}
}
componentDidMount () {
if (this.waveform) {
this._updateWaveform();
}
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentDidUpdate (prevProps) {
if (this.waveform && prevProps.src !== this.props.src) {
this._updateWaveform();
componentDidUpdate (prevProps, prevState) {
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
this._clear();
this._draw();
}
}
componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll);
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;
window.removeEventListener('resize', this.handleResize);
}
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.setState({ paused: false }, () => this.wavesurfer.play());
this.setState({ paused: false }, () => this.audio.play());
} else {
this.setState({ paused: true }, () => this.wavesurfer.pause());
this.setState({ paused: true }, () => this.audio.pause());
}
}
handleResize = debounce(() => {
if (this.player) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handlePlay = () => {
this.setState({ paused: false });
if (this.canvas && !this.audioContext) {
this._initAudioContext();
}
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
this._renderCanvas();
}
handlePause = () => {
this.setState({ paused: true });
if (this.audioContext) {
this.audioContext.suspend();
}
}
handleProgress = () => {
const lastTimeRange = this.audio.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
}
}
toggleMute = () => {
const muted = !this.state.muted;
this.setState({ muted }, () => this.wavesurfer.setMute(muted));
this.setState({ muted }, () => {
this.audio.muted = muted;
});
}
handleVolumeMouseDown = e => {
@ -161,86 +187,361 @@ class Audio extends React.PureComponent {
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
document.addEventListener('mouseup', this.handleMouseUp, true);
document.addEventListener('touchmove', this.handleMouseMove, true);
document.addEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: true });
this.audio.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove, true);
document.removeEventListener('mouseup', this.handleMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseMove, true);
document.removeEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: false });
this.audio.play();
}
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = this.audio.duration * x;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}, 15);
handleTimeUpdate = () => {
this.setState({
currentTime: this.audio.currentTime,
duration: Math.floor(this.audio.duration),
});
}
handleMouseVolSlide = throttle(e => {
const rect = this.volume.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.
const { x } = getPointerPosition(this.volume, e);
if(!isNaN(x)) {
let slideamt = x;
if (x > 1) {
slideamt = 1;
} else if(x < 0) {
slideamt = 0;
}
this.wavesurfer.setVolume(slideamt);
this.setState({ volume: x }, () => {
this.audio.volume = x;
});
}
}, 60);
}, 15);
handleScroll = throttle(() => {
if (!this.waveform || !this.wavesurfer) {
if (!this.canvas || !this.audio) {
return;
}
const { top, height } = this.waveform.getBoundingClientRect();
const { top, height } = this.canvas.getBoundingClientRect();
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) {
this.setState({ paused: true }, () => this.wavesurfer.pause());
this.setState({ paused: true }, () => this.audio.pause());
}
}, 150, { trailing: true })
}, 150, { trailing: true });
handleMouseEnter = () => {
this.setState({ hovered: true });
}
handleMouseLeave = () => {
this.setState({ hovered: false });
}
_initAudioContext () {
const context = new AudioContext();
const analyser = context.createAnalyser();
const source = context.createMediaElementSource(this.audio);
analyser.smoothingTimeConstant = 0.6;
analyser.fftSize = 2048;
source.connect(analyser);
source.connect(context.destination);
this.audioContext = context;
this.analyser = analyser;
}
handleDownload = () => {
fetch(this.props.src).then(res => res.blob()).then(blob => {
const element = document.createElement('a');
const objectURL = URL.createObjectURL(blob);
element.setAttribute('href', objectURL);
element.setAttribute('download', fileNameFromURL(this.props.src));
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
URL.revokeObjectURL(objectURL);
}).catch(err => {
console.error(err);
});
}
_renderCanvas () {
requestAnimationFrame(() => {
this.handleTimeUpdate();
this._clear();
this._draw();
if (!this.state.paused) {
this._renderCanvas();
}
});
}
_clear () {
this.canvasContext.clearRect(0, 0, this.state.width, this.state.height);
}
_draw () {
this.canvasContext.save();
const ticks = this._getTicks(360 * this._getScaleCoefficient(), TICK_SIZE);
ticks.forEach(tick => {
this._drawTick(tick.x1, tick.y1, tick.x2, tick.y2);
});
this.canvasContext.restore();
}
_getRadius () {
return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
}
_getScaleCoefficient () {
return (this.state.height || this.props.height) / 982;
}
_getTicks (count, size, animationParams = [0, 90]) {
const radius = this._getRadius();
const ticks = this._getTickPoints(count);
const lesser = 200;
const m = [];
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
const frequencyData = new Uint8Array(bufferLength);
const allScales = [];
const scaleCoefficient = this._getScaleCoefficient();
if (this.analyser) {
this.analyser.getByteFrequencyData(frequencyData);
}
ticks.forEach((tick, i) => {
const coef = 1 - i / (ticks.length * 2.5);
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
if (delta < 0) {
delta = 0;
}
let k;
if (animationParams[0] <= tick.angle && tick.angle <= animationParams[1]) {
k = radius / (radius - this._getSize(tick.angle, animationParams[0], animationParams[1]) - delta);
} else {
k = radius / (radius - (size + delta));
}
const x1 = tick.x * (radius - size);
const y1 = tick.y * (radius - size);
const x2 = x1 * k;
const y2 = y1 * k;
m.push({ x1, y1, x2, y2 });
if (i < 20) {
let scale = delta / (200 * scaleCoefficient);
scale = scale < 1 ? 1 : scale;
allScales.push(scale);
}
});
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
return m.map(({ x1, y1, x2, y2 }) => ({
x1: x1,
y1: y1,
x2: x2 * scale,
y2: y2 * scale,
}));
}
_getSize (angle, l, r) {
const scaleCoefficient = this._getScaleCoefficient();
const maxTickSize = TICK_SIZE * 9 * scaleCoefficient;
const m = (r - l) / 2;
const x = (angle - l);
let h;
if (x === m) {
return maxTickSize;
}
const d = Math.abs(m - x);
const v = 40 * Math.sqrt(1 / d);
if (v > maxTickSize) {
h = maxTickSize;
} else {
h = Math.max(TICK_SIZE, v);
}
return h;
}
_getTickPoints (count) {
const PI = 360;
const coords = [];
const step = PI / count;
let rad;
for(let deg = 0; deg < PI; deg += step) {
rad = deg * Math.PI / (PI / 2);
coords.push({ x: Math.cos(rad), y: -Math.sin(rad), angle: deg });
}
return coords;
}
_drawTick (x1, y1, x2, y2) {
const cx = this._getCX();
const cy = this._getCY();
const dx1 = Math.ceil(cx + x1);
const dy1 = Math.ceil(cy + y1);
const dx2 = Math.ceil(cx + x2);
const dy2 = Math.ceil(cy + y2);
const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2);
const mainColor = this._getAccentColor();
const lastColor = hex2rgba(mainColor, 0);
gradient.addColorStop(0, mainColor);
gradient.addColorStop(0.6, mainColor);
gradient.addColorStop(1, lastColor);
this.canvasContext.beginPath();
this.canvasContext.strokeStyle = gradient;
this.canvasContext.lineWidth = 2;
this.canvasContext.moveTo(dx1, dy1);
this.canvasContext.lineTo(dx2, dy2);
this.canvasContext.stroke();
}
_getCX() {
return Math.floor(this.state.width / 2);
}
_getCY() {
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
}
_getAccentColor () {
return this.props.accentColor || '#ffffff';
}
_getBackgroundColor () {
return this.props.backgroundColor || '#000000';
}
_getForegroundColor () {
return this.props.foregroundColor || '#ffffff';
}
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);
const { src, intl, alt, editable } = this.props;
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
const progress = (currentTime / duration) * 100;
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={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<audio
src={src}
ref={this.setAudioRef}
preload='none'
onPlay={this.handlePlay}
onPause={this.handlePause}
onProgress={this.handleProgress}
crossOrigin='anonymous'
/>
<canvas
role='button'
className='audio-player__canvas'
width={this.state.width}
height={this.state.height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={this.setCanvasRef}
onClick={this.togglePlay}
title={alt}
aria-label={alt}
/>
<img
src={this.props.poster}
alt=''
width={(this._getRadius() - TICK_SIZE) * 2}
height={(this._getRadius() - TICK_SIZE) * 2}
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
/>
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex='0'
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
/>
</div>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} 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}>
&nbsp;
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
<span
className={classNames('video-player__volume__handle')}
tabIndex='0'
style={{ left: `${volumeHandleLoc}px` }}
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
/>
</div>
<span>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(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 className='video-player__buttons right'>
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}>
<a className='video-player__download__icon' href={this.props.src} download>
<Icon id={'download'} fixedWidth />
</a>
</button>
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
</div>
</div>
</div>

View File

@ -203,7 +203,7 @@ class EmojiPickerMenu extends React.PureComponent {
if (!emoji.native) {
emoji.native = emoji.colons;
}
if (!event.ctrlKey) {
if (!(event.ctrlKey || event.metaKey)) {
this.props.onClose();
}
this.props.onPick(emoji);

View File

@ -7,11 +7,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media ({formats})' },
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
});
const SUPPORTED_FORMATS = 'JPEG, PNG, GIF, WebM, MP4, MOV, OGG, WAV, MP3, FLAC';
const makeMapStateToProps = () => {
const mapStateToProps = state => ({
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
@ -60,11 +58,13 @@ class UploadButton extends ImmutablePureComponent {
return null;
}
const message = intl.formatMessage(messages.upload);
return (
<div className='compose-form__upload-button'>
<IconButton icon='paperclip' title={intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<IconButton icon='paperclip' title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })}</span>
<span style={{ display: 'none' }}>{message}</span>
<input
key={resetFileKey}
ref={this.setRef}

View File

@ -11,8 +11,14 @@ 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 ShortNumber from 'mastodon/components/short_number';
import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes';
@ -22,7 +28,10 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
},
});
const makeMapStateToProps = () => {
@ -36,15 +45,25 @@ const makeMapStateToProps = () => {
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
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'))),
}));
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')));
}
@ -53,7 +72,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onBlock (account) {
onBlock(account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
@ -61,17 +80,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onMute (account) {
onMute(account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(initMuteModal(account));
}
},
});
export default @injectIntl
export default
@injectIntl
@connect(makeMapStateToProps, mapDispatchToProps)
class AccountCard extends ImmutablePureComponent {
@ -83,7 +102,7 @@ class AccountCard extends ImmutablePureComponent {
onMute: PropTypes.func.isRequired,
};
_updateEmojis () {
_updateEmojis() {
const node = this.node;
if (!node || autoPlayGif) {
@ -104,68 +123,113 @@ class AccountCard extends ImmutablePureComponent {
}
}
componentDidMount () {
componentDidMount() {
this._updateEmojis();
}
componentDidUpdate () {
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 () {
render() {
const { account, intl } = this.props;
let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) {
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']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.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} />;
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} />;
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} />;
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='' />
<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')}`}>
<Permalink
className='directory__card__bar__name'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<Avatar account={account} size={48} />
<DisplayName account={account} />
</Permalink>
@ -176,13 +240,44 @@ class AccountCard extends ImmutablePureComponent {
</div>
<div className='directory__card__extra' ref={this.setRef}>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
<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 className='accounts-table__count'>
<ShortNumber value={account.get('statuses_count')} />
<small>
<FormattedMessage id='account.posts' defaultMessage='Toots' />
</small>
</div>
<div className='accounts-table__count'>
<ShortNumber value={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>
);

View File

@ -76,7 +76,17 @@ describe('emoji', () => {
it('skips the textual presentation VS15 character', () => {
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734.svg" />');
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg" />');
});
it('does an simple emoji properly', () => {
expect(emojify('♀♂'))
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg" /><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg" />');
});
it('does an emoji containing ZWJ properly', () => {
expect(emojify('💂‍♀️💂‍♂️'))
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg" /><img draggable="false" class="emojione" alt="💂\u200D♂" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg" />');
});
});
});

View File

@ -6,6 +6,20 @@ const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
// Convert to file names from emojis. (For different variation selector emojis)
const emojiFilenames = (emojis) => {
return emojis.map(v => unicodeMapping[v].filename);
};
// Emoji requiring extra borders depending on theme
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴']);
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
const emojiFilename = (filename) => {
const borderedEmoji = (document.body && document.body.classList.contains('theme-mastodon-light')) ? lightEmoji : darkEmoji;
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
};
const emojify = (str, customEmojis = {}) => {
const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
@ -60,7 +74,7 @@ const emojify = (str, customEmojis = {}) => {
} else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : '';
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`;
rend = i + match.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend) === 65038) {

View File

@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container';
import ColumnBackButton from '../../components/column_back_button';
import ScrollableList from '../../components/scrollable_list';
import MissingIndicator from 'mastodon/components/missing_indicator';
import TimelineHint from 'mastodon/components/timeline_hint';
const mapStateToProps = (state, props) => ({
remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
});
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
);
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
};
export default @connect(mapStateToProps)
class Followers extends ImmutablePureComponent {
@ -38,6 +49,8 @@ class Followers extends ImmutablePureComponent {
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool,
isAccount: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
@ -60,7 +73,7 @@ class Followers extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
if (!isAccount) {
return (
@ -78,7 +91,17 @@ class Followers extends ImmutablePureComponent {
);
}
const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
let emptyMessage;
if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
return (
<Column>
@ -92,6 +115,7 @@ class Followers extends ImmutablePureComponent {
shouldUpdateScroll={shouldUpdateScroll}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

View File

@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container';
import ColumnBackButton from '../../components/column_back_button';
import ScrollableList from '../../components/scrollable_list';
import MissingIndicator from 'mastodon/components/missing_indicator';
import TimelineHint from 'mastodon/components/timeline_hint';
const mapStateToProps = (state, props) => ({
remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
});
const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
);
RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
};
export default @connect(mapStateToProps)
class Following extends ImmutablePureComponent {
@ -38,6 +49,8 @@ class Following extends ImmutablePureComponent {
isLoading: PropTypes.bool,
blockedBy: PropTypes.bool,
isAccount: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};
@ -60,7 +73,7 @@ class Following extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
if (!isAccount) {
return (
@ -78,7 +91,17 @@ class Following extends ImmutablePureComponent {
);
}
const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
let emptyMessage;
if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
return (
<Column>
@ -92,6 +115,7 @@ class Following extends ImmutablePureComponent {
shouldUpdateScroll={shouldUpdateScroll}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

View File

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import AsyncSelect from 'react-select/async';
import { NonceProvider } from 'react-select';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
@ -58,18 +59,20 @@ class ColumnSettings extends React.PureComponent {
{this.modeLabel(mode)}
</span>
<AsyncSelect
isMulti
autoFocus
value={this.tags(mode)}
onChange={this.onSelect(mode)}
loadOptions={this.props.onLoad}
className='column-select__container'
classNamePrefix='column-select'
name='tags'
placeholder={this.props.intl.formatMessage(messages.placeholder)}
noOptionsMessage={this.noOptionsMessage}
/>
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}>
<AsyncSelect
isMulti
autoFocus
value={this.tags(mode)}
onChange={this.onSelect(mode)}
loadOptions={this.props.onLoad}
className='column-select__container'
classNamePrefix='column-select'
name='tags'
placeholder={this.props.intl.formatMessage(messages.placeholder)}
noOptionsMessage={this.noOptionsMessage}
/>
</NonceProvider>
</div>
);
}

View File

@ -88,6 +88,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td>
</tr>
<tr>
<td><kbd>alt</kbd>+<kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
</tr>
<tr>
<td><kbd>backspace</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>

View File

@ -201,10 +201,6 @@ class ActionBar extends React.PureComponent {
if (me === status.getIn(['account', 'id'])) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
} else {
if (status.get('visibility') === 'private') {
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
}
}
menu.push(null);
@ -261,14 +257,23 @@ class ActionBar extends React.PureComponent {
replyIcon = 'reply-all';
}
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let reblogTitle;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
return (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus} active={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View File

@ -2,9 +2,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import Immutable from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import punycode from 'punycode';
import classnames from 'classnames';
import Icon from 'mastodon/components/icon';
import { useBlurhash } from 'mastodon/initial_state';
import { decode } from 'blurhash';
import { debounce } from 'lodash';
const IDNA_PREFIX = 'xn--';
@ -63,6 +67,7 @@ export default class Card extends React.PureComponent {
compact: PropTypes.bool,
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
};
static defaultProps = {
@ -72,15 +77,72 @@ export default class Card extends React.PureComponent {
state = {
width: this.props.defaultWidth || 280,
previewLoaded: false,
embedded: false,
revealed: !this.props.sensitive,
};
componentWillReceiveProps (nextProps) {
if (!Immutable.is(this.props.card, nextProps.card)) {
this.setState({ embedded: false });
this.setState({ embedded: false, previewLoaded: false });
}
if (this.props.sensitive !== nextProps.sensitive) {
this.setState({ revealed: !nextProps.sensitive });
}
}
componentDidMount () {
window.addEventListener('resize', this.handleResize, { passive: true });
if (this.props.card && this.props.card.get('blurhash') && this.canvas) {
this._decode();
}
}
componentWillUnmount () {
window.removeEventListener('resize', this.handleResize);
}
componentDidUpdate (prevProps) {
const { card } = this.props;
if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash')) && this.canvas) {
this._decode();
}
}
_decode () {
if (!useBlurhash) return;
const hash = this.props.card.get('blurhash');
const pixels = decode(hash, 32, 32);
if (pixels) {
const ctx = this.canvas.getContext('2d');
const imageData = new ImageData(pixels, 32, 32);
ctx.putImageData(imageData, 0, 0);
}
}
_setDimensions () {
const width = this.node.offsetWidth;
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({ width });
}
handleResize = debounce(() => {
if (this.node) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handlePhotoClick = () => {
const { card, onOpenMedia } = this.props;
@ -113,12 +175,27 @@ export default class Card extends React.PureComponent {
}
setRef = c => {
if (c) {
if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
this.setState({ width: c.offsetWidth });
this.node = c;
if (this.node) {
this._setDimensions();
}
}
setCanvasRef = c => {
this.canvas = c;
}
handleImageLoad = () => {
this.setState({ previewLoaded: true });
}
handleReveal = e => {
e.preventDefault();
e.stopPropagation();
this.setState({ revealed: true });
}
renderVideo () {
const { card } = this.props;
const content = { __html: addAutoPlay(card.get('html')) };
@ -138,7 +215,7 @@ export default class Card extends React.PureComponent {
render () {
const { card, maxDescription, compact } = this.props;
const { width, embedded } = this.state;
const { width, embedded, revealed } = this.state;
if (card === null) {
return null;
@ -161,7 +238,18 @@ export default class Card extends React.PureComponent {
);
let embed = '';
let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classnames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
let spoilerButton = (
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
</button>
);
spoilerButton = (
<div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
{spoilerButton}
</div>
);
if (interactive) {
if (embedded) {
@ -175,20 +263,24 @@ export default class Card extends React.PureComponent {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
<div className='status-card__actions'>
<div>
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
{revealed && (
<div className='status-card__actions'>
<div>
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
</div>
</div>
</div>
)}
{!revealed && spoilerButton}
</div>
);
}
return (
<div className={className} ref={this.setRef}>
<div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
{embed}
{!compact && description}
</div>
@ -196,6 +288,7 @@ export default class Card extends React.PureComponent {
} else if (card.get('image')) {
embed = (
<div className='status-card__image'>
{canvas}
{thumbnail}
</div>
);

View File

@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom';
import { FormattedDate } from 'react-intl';
import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
@ -16,7 +16,15 @@ import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
export default class DetailedStatus extends ImmutablePureComponent {
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
export default @injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@ -92,7 +100,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };
const { compact } = this.props;
const { intl, compact } = this.props;
if (!status) {
return null;
@ -117,8 +125,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
height={110}
preload
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
height={150}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@ -153,38 +164,48 @@ export default class DetailedStatus extends ImmutablePureComponent {
);
}
} else if (status.get('spoiler_text').length === 0) {
media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
}
if (status.get('application')) {
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></span>;
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
}
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
const visibilityLink = <React.Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></React.Fragment>;
if (['private', 'direct'].includes(status.get('visibility'))) {
reblogLink = <Icon id={reblogIcon} />;
reblogLink = '';
} else if (this.context.router) {
reblogLink = (
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</Link>
<React.Fragment>
<React.Fragment> · </React.Fragment>
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</Link>
</React.Fragment>
);
} else {
reblogLink = (
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</a>
<React.Fragment>
<React.Fragment> · </React.Fragment>
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</a>
</React.Fragment>
);
}
@ -210,7 +231,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
@ -223,7 +244,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{applicationLink} · {reblogLink} · {favouriteLink}
</a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</div>
</div>
</div>

View File

@ -1,25 +1,24 @@
import { render, fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { mount } from 'enzyme';
import Column from '../column';
import ColumnHeader from '../column_header';
describe('<Column />', () => {
describe('<ColumnHeader /> click handler', () => {
it('runs the scroll animation if the column contains scrollable content', () => {
const wrapper = mount(
const scrollToMock = jest.fn();
const { container } = render(
<Column heading='notifications'>
<div className='scrollable' />
</Column>,
);
const scrollToMock = jest.fn();
wrapper.find(Column).find('.scrollable').getDOMNode().scrollTo = scrollToMock;
wrapper.find(ColumnHeader).find('button').simulate('click');
container.querySelector('.scrollable').scrollTo = scrollToMock;
fireEvent.click(screen.getByText('notifications'));
expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
});
it('does not try to scroll if there is no scrollable content', () => {
const wrapper = mount(<Column heading='notifications' />);
wrapper.find(ColumnHeader).find('button').simulate('click');
render(<Column heading='notifications' />);
fireEvent.click(screen.getByText('notifications'));
});
});
});

View File

@ -59,8 +59,11 @@ export default class AudioModal extends ImmutablePureComponent {
src={media.get('url')}
alt={media.get('description')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={135}
preload
height={150}
poster={media.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
accentColor={media.getIn(['meta', 'colors', 'accent'])}
/>
</div>

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { changeUploadCompose } from '../../../actions/compose';
import { changeUploadCompose, uploadThumbnail } from '../../../actions/compose';
import { getPointerPosition } from '../../video';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button';
@ -17,15 +17,19 @@ import CharacterCounter from 'mastodon/features/compose/components/character_cou
import { length } from 'stringz';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import GIFV from 'mastodon/components/gifv';
import { me } from 'mastodon/initial_state';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
});
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
account: state.getIn(['accounts', me]),
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
});
const mapDispatchToProps = (dispatch, { id }) => ({
@ -34,6 +38,10 @@ const mapDispatchToProps = (dispatch, { id }) => ({
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
},
onSelectThumbnail: files => {
dispatch(uploadThumbnail(id, files[0]));
},
});
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
@ -78,6 +86,10 @@ class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.map.isRequired,
isUploadingThumbnail: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onSelectThumbnail: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -232,13 +244,29 @@ class FocalPointModal extends ImmutablePureComponent {
}).catch(() => this.setState({ detecting: false }));
}
handleThumbnailChange = e => {
if (e.target.files.length > 0) {
this.setState({ dirty: true });
this.props.onSelectThumbnail(e.target.files);
}
}
setFileInputRef = c => {
this.fileInput = c;
}
handleFileInputClick = () => {
this.fileInput.click();
}
render () {
const { media, intl, onClose } = this.props;
const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
const { x, y, dragging, description, dirty, detecting, progress } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
const focals = ['image', 'gifv'].includes(media.get('type'));
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
const previewRatio = 16/9;
const previewWidth = 200;
@ -265,6 +293,30 @@ class FocalPointModal extends ImmutablePureComponent {
<div className='report-modal__comment'>
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
{thumbnailable && (
<React.Fragment>
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
<input
id='upload-modal__thumbnail'
ref={this.setFileInputRef}
type='file'
accept='image/png,image/jpeg'
onChange={this.handleThumbnailChange}
style={{ display: 'none' }}
disabled={isUploadingThumbnail}
/>
</label>
<hr className='setting-divider' />
</React.Fragment>
)}
<label className='setting-text-label' htmlFor='upload-modal__description'>
{descriptionLabel}
</label>
@ -290,7 +342,7 @@ class FocalPointModal extends ImmutablePureComponent {
<CharacterCounter max={1500} text={detecting ? '' : description} />
</div>
<Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
<Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
</div>
<div className='focal-point-modal__content'>
@ -325,7 +377,10 @@ class FocalPointModal extends ImmutablePureComponent {
src={media.get('url')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
poster={media.get('preview_url') || account.get('avatar_static')}
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
accentColor={media.getIn(['meta', 'colors', 'accent'])}
editable
/>
)}

View File

@ -17,6 +17,8 @@ const makeGetStatusIds = (pending = false) => createSelector([
const statusForId = statuses.get(id);
let showStatus = true;
if (statusForId.get('account') === me) return true;
if (columnSettings.getIn(['shows', 'reblog']) === false) {
showStatus = showStatus && statusForId.get('reblog') === null;
}

View File

@ -10,7 +10,7 @@ import LoadingBarContainer from './containers/loading_bar_container';
import ModalContainer from './containers/modal_container';
import { isMobile } from '../../is_mobile';
import { debounce } from 'lodash';
import { uploadCompose, resetCompose } from '../../actions/compose';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters';
@ -76,6 +76,7 @@ const keyMap = {
new: 'n',
search: 's',
forceNew: 'option+n',
toggleComposeSpoilers: 'option+x',
focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
reply: 'r',
favourite: 'f',
@ -375,7 +376,7 @@ class UI extends React.PureComponent {
componentDidMount () {
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
};
}
@ -420,6 +421,11 @@ class UI extends React.PureComponent {
this.props.dispatch(resetCompose());
}
handleHotkeyToggleComposeSpoilers = e => {
e.preventDefault();
this.props.dispatch(changeComposeSpoilerness());
}
handleHotkeyFocusColumn = e => {
const index = (e.key * 1) + 1; // First child is drawer, skip that
const column = this.node.querySelector(`.column:nth-child(${index})`);
@ -515,6 +521,7 @@ class UI extends React.PureComponent {
new: this.handleHotkeyNew,
search: this.handleHotkeySearch,
forceNew: this.handleHotkeyForceNew,
toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
focusColumn: this.handleHotkeyFocusColumn,
back: this.handleHotkeyBack,
goToHome: this.handleHotkeyGoToHome,

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { fromJS, is } from 'immutable';
import { throttle } from 'lodash';
import { throttle, debounce } from 'lodash';
import classNames from 'classnames';
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
import { displayMedia, useBlurhash } from '../../initial_state';
@ -19,7 +19,6 @@ const messages = defineMessages({
close: { id: 'video.close', defaultMessage: 'Close video' },
fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
download: { id: 'video.download', defaultMessage: 'Download file' },
});
export const formatTime = secondsNum => {
@ -87,6 +86,14 @@ export const getPointerPosition = (el, event) => {
return position;
};
export const fileNameFromURL = str => {
const url = new URL(str);
const pathname = url.pathname;
const index = pathname.lastIndexOf('/');
return pathname.substring(index + 1);
};
export default @injectIntl
class Video extends React.PureComponent {
@ -126,29 +133,26 @@ class Video extends React.PureComponent {
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
};
// 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;
}
setPlayerRef = c => {
this.player = c;
if (c) {
if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
this.setState({
containerWidth: c.offsetWidth,
});
if (this.player) {
this._setDimensions();
}
}
_setDimensions () {
const width = this.player.offsetWidth;
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({
containerWidth: width,
});
}
setVideoRef = c => {
this.video = c;
@ -173,15 +177,26 @@ class Video extends React.PureComponent {
handlePlay = () => {
this.setState({ paused: false });
this._updateTime();
}
handlePause = () => {
this.setState({ paused: true });
}
_updateTime () {
requestAnimationFrame(() => {
this.handleTimeUpdate();
if (!this.state.paused) {
this._updateTime();
}
});
}
handleTimeUpdate = () => {
this.setState({
currentTime: Math.floor(this.video.currentTime),
currentTime: this.video.currentTime,
duration: Math.floor(this.video.duration),
});
}
@ -206,22 +221,14 @@ class Video extends React.PureComponent {
}
handleMouseVolSlide = throttle(e => {
const rect = this.volume.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
const { x } = getPointerPosition(this.volume, e);
if(!isNaN(x)) {
let slideamt = x;
if(x > 1) {
slideamt = 1;
} else if(x < 0) {
slideamt = 0;
}
this.video.volume = slideamt;
this.setState({ volume: slideamt });
this.setState({ volume: x }, () => {
this.video.volume = x;
});
}
}, 60);
}, 15);
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
@ -249,13 +256,14 @@ class Video extends React.PureComponent {
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = Math.floor(this.video.duration * x);
const currentTime = this.video.duration * x;
if (!isNaN(currentTime)) {
this.video.currentTime = currentTime;
this.setState({ currentTime });
this.setState({ currentTime }, () => {
this.video.currentTime = currentTime;
});
}
}, 60);
}, 15);
togglePlay = () => {
if (this.state.paused) {
@ -280,6 +288,7 @@ class Video extends React.PureComponent {
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
if (this.props.blurhash) {
this._decode();
@ -288,6 +297,7 @@ class Video extends React.PureComponent {
componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
@ -325,6 +335,14 @@ class Video extends React.PureComponent {
}
}
handleResize = debounce(() => {
if (this.player) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handleScroll = throttle(() => {
if (!this.video) {
return;
@ -381,8 +399,10 @@ class Video extends React.PureComponent {
}
handleProgress = () => {
if (this.video.buffered.length > 0) {
this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
const lastTimeRange = this.video.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
}
}
@ -421,9 +441,6 @@ class Video extends React.PureComponent {
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = (currentTime / duration) * 100;
const volumeWidth = (muted) ? 0 : volume * this.volWidth;
const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
const playerStyle = {};
let { width, height } = this.props;
@ -481,7 +498,6 @@ class Video extends React.PureComponent {
onClick={this.togglePlay}
onPlay={this.handlePlay}
onPause={this.handlePause}
onTimeUpdate={this.handleTimeUpdate}
onLoadedData={this.handleLoadedData}
onProgress={this.handleProgress}
onVolumeChange={this.handleVolumeChange}
@ -510,19 +526,19 @@ class Video extends React.PureComponent {
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} 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}>
&nbsp;
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
<div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
<div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} />
<span
className={classNames('video-player__volume__handle')}
tabIndex='0'
style={{ left: `${volumeHandleLoc}px` }}
style={{ left: `${volume * 100}%` }}
/>
</div>
{(detailed || fullscreen) && (
<span>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(duration)}</span>
</span>
@ -535,7 +551,6 @@ class Video extends React.PureComponent {
{(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
{(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
{onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
<button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
</div>
</div>

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "أضفه أو أزله من القائمة",
"account.badges.bot": "روبوت",
"account.badges.group": "فريق",
"account.block": "حظر @{name}",
"account.block_domain": "إخفاء كل شيء قادم من اسم النطاق {domain}",
"account.blocked": "محظور",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "إلغاء طلب المتابَعة",
"account.direct": "رسالة خاصة إلى @{name}",
"account.domain_blocked": "النطاق مخفي",
@ -39,6 +42,10 @@
"account.unfollow": "إلغاء المتابعة",
"account.unmute": "إلغاء الكتم عن @{name}",
"account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "يرجى إعادة المحاولة بعد {retry_time, time, medium}.",
"alert.rate_limited.title": "المعدل محدود",
"alert.unexpected.message": "لقد طرأ هناك خطأ غير متوقّع.",
@ -74,9 +81,9 @@
"column_header.show_settings": "عرض الإعدادات",
"column_header.unpin": "فك التدبيس",
"column_subheading.settings": "الإعدادات",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "المحلي فقط",
"community.column_settings.media_only": "الوسائط فقط",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "عن بُعد فقط",
"compose_form.direct_message_warning": "لن يَظهر هذا التبويق إلا للمستخدمين المذكورين.",
"compose_form.direct_message_warning_learn_more": "اقرأ المزيد",
"compose_form.hashtag_warning": "هذا التبويق لن يُدرَج تحت أي وسم كان بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن التبويقات العمومية عن طريق الوسوم.",
@ -110,7 +117,7 @@
"confirmations.logout.confirm": "خروج",
"confirmations.logout.message": "متأكد من أنك تريد الخروج؟",
"confirmations.mute.confirm": "أكتم",
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
"confirmations.mute.explanation": "هذا سيخفي المنشورات عنهم وتلك المشار فيها إليهم، لكنه سيسمح لهم برؤية منشوراتك ومتابعتك.",
"confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
"confirmations.redraft.confirm": "إزالة و إعادة الصياغة",
"confirmations.redraft.message": "هل أنت متأكد من أنك تريد حذف هذا المنشور و إعادة صياغته؟ سوف تفقد جميع الإعجابات و الترقيات أما الردود المتصلة به فستُصبِح يتيمة.",
@ -160,7 +167,7 @@
"empty_column.mutes": "لم تقم بكتم أي مستخدم بعد.",
"empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
"empty_column.public": "لا يوجد أي شيء هنا! قم بنشر شيء ما للعامة، أو اتبع المستخدمين الآخرين المتواجدين على الخوادم الأخرى لملء خيط المحادثات",
"error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
"error.unexpected_crash.explanation": "نظرا لوجود خطأ في التعليمات البرمجية أو مشكلة توافق مع المتصفّح، تعذر عرض هذه الصفحة بشكل صحيح.",
"error.unexpected_crash.next_steps": "حاول إعادة إنعاش الصفحة. إن لم تُحلّ المشكلة ، يمكنك دائمًا استخدام ماستدون عبر متصفّح آخر أو تطبيق أصلي.",
"errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة",
"errors.unexpected_crash.report_issue": "الإبلاغ عن خلل",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "للردّ",
"keyboard_shortcuts.requests": "لفتح قائمة طلبات المتابعة",
"keyboard_shortcuts.search": "للتركيز على البحث",
"keyboard_shortcuts.spoilers": "لإظهار/إخفاء حقلCW",
"keyboard_shortcuts.start": "لفتح عمود \"هيا نبدأ\"",
"keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير",
"keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# دقيقة} other {# دقائق}} متبقية",
"time_remaining.moments": "لحظات متبقية",
"time_remaining.seconds": "{number, plural, one {# ثانية} other {# ثوانٍ}} متبقية",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "المتابِعون",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "التبويقات القديمة",
"trends.count_by_accounts": "{count} {rawCount, plural, zero {} one {شخص واحد} two {شخصين} few {أشخاص} many {أشخاص} other {أشخاص}} تتحدّث",
"trends.trending_now": "المتداولة الآن",
"ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.",

View File

@ -1,10 +1,13 @@
{
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Amestar o desaniciar de les llistes",
"account.badges.bot": "Robó",
"account.badges.group": "Grupu",
"account.block": "Bloquiar a @{name}",
"account.block_domain": "Anubrir tolo de {domain}",
"account.blocked": "Blocked",
"account.blocked": "Bloquiada",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Encaboxar la solicitú de siguimientu",
"account.direct": "Unviar un mensaxe direutu a @{name}",
"account.domain_blocked": "Dominiu anubríu",
@ -13,12 +16,12 @@
"account.follow": "Siguir",
"account.followers": "Siguidores",
"account.followers.empty": "Naide sigue a esti usuariu entá.",
"account.follows": "Follows",
"account.follows": "Sigue",
"account.follows.empty": "Esti usuariu entá nun sigue a naide.",
"account.follows_you": "Síguete",
"account.hide_reblogs": "Anubrir les comparticiones de @{name}",
"account.last_status": "Last active",
"account.link_verified_on": "Ownership of this link was checked on {date}",
"account.last_status": "Cabera actividá",
"account.link_verified_on": "La propiedá d'esti enllaz foi comprobada'l {date}",
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.media": "Media",
"account.mention": "Mentar a @{name}",
@ -39,6 +42,10 @@
"account.unfollow": "Dexar de siguir",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "Asocedió un fallu inesperáu.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "pa responder",
"keyboard_shortcuts.requests": "p'abrir la llista de solicitúes de siguimientu",
"keyboard_shortcuts.search": "pa enfocar la gueta",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "p'abrir la columna «entamar»",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -412,11 +420,15 @@
"time_remaining.minutes": "{number, plural, one {# minutu restante} other {# minutos restantes}}",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# segundu restante} other {# segundos restantes}}",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} falando",
"trends.trending_now": "Trending now",
"ui.beforeunload": "El borrador va perdese si coles de Mastodon.",
"upload_area.title": "Arrastra y suelta pa xubir",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "La xuba de ficheros nun ta permitida con encuestes.",
"upload_form.audio_description": "Descripción pa persones con perda auditiva",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Добави или премахни от списъците",
"account.badges.bot": "бот",
"account.badges.group": "Group",
"account.block": "Блокирай",
"account.block_domain": "скрий всичко от (домейн)",
"account.blocked": "Блокирани",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Откажи искането за следване",
"account.direct": "Direct Message @{name}",
"account.domain_blocked": "Скрит домейн",
@ -39,6 +42,10 @@
"account.unfollow": "Не следвай",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "An unexpected error occurred.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "to reply",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "তালিকাতে যুক্ত বা অপসারণ করুন",
"account.badges.bot": "বট",
"account.badges.group": "Group",
"account.block": "@{name} কে ব্লক করুন",
"account.block_domain": "{domain} থেকে সব আড়াল করুন",
"account.blocked": "অবরুদ্ধ",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "অনুসরণ অনুরোধ বাতিল করুন",
"account.direct": "@{name} কে সরাসরি বার্তা",
"account.domain_blocked": "ডোমেন গোপন করুন",
@ -39,6 +42,10 @@
"account.unfollow": "অনুসরণ না করতে",
"account.unmute": "@{name} র কার্যকলাপ আবার দেখুন",
"account.unmute_notifications": "@{name} র প্রজ্ঞাপন দেখুন",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "{retry_time, time, medium} -এর পরে আবার প্রচেষ্টা করুন।",
"alert.rate_limited.title": "হার সীমিত",
"alert.unexpected.message": "সমস্যা অপ্রত্যাশিত.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "মতামত দিতে",
"keyboard_shortcuts.requests": "অনুসরণ অনুরোধের তালিকা দেখতে",
"keyboard_shortcuts.search": "খোঁজার অংশে ফোকাস করতে",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "\"প্রথম শুরুর\" কলাম বের করতে",
"keyboard_shortcuts.toggle_hidden": "CW লেখা দেখতে বা লুকাতে",
"keyboard_shortcuts.toggle_sensitivity": "ভিডিও/ছবি দেখতে বা বন্ধ করতে",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# মিনিট} other {# মিনিট}} বাকি আছে",
"time_remaining.moments": "সময় বাকি আছে",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} বাকি আছে",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} কথা বলছে",
"trends.trending_now": "বর্তমানে জনপ্রিয়",
"ui.beforeunload": "যে পর্যন্ত এটা লেখা হয়েছে, মাস্টাডন থেকে চলে গেলে এটা মুছে যাবে।",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Ouzhpenn pe dilemel eus al listennadoù",
"account.badges.bot": "Robot",
"account.badges.group": "Strollad",
"account.block": "Berzañ @{name}",
"account.block_domain": "Berzañ pep tra eus {domain}",
"account.blocked": "Stanket",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Nullañ ar bedadenn heuliañ",
"account.direct": "Kas ur gemennadenn da @{name}",
"account.domain_blocked": "Domani berzet",
@ -39,6 +42,10 @@
"account.unfollow": "Diheuliañ",
"account.unmute": "Diguzhat @{name}",
"account.unmute_notifications": "Diguzhat kemennoù a @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Klaskit en-dro a-benn {retry_time, time, medium}.",
"alert.rate_limited.title": "Feur bevennet",
"alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "da respont",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -257,7 +265,7 @@
"lists.subheading": "Ho listennoù",
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"loading_indicator.label": "O kargañ...",
"media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
"media_gallery.toggle_visible": "Toggle visibility",
"missing_indicator.label": "Digavet",
"missing_indicator.sublabel": "This resource could not be found",
"mute_modal.hide_notifications": "Hide notifications from this user?",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.trending_now": "Luskad ar mare",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Afegir o Treure de les llistes",
"account.badges.bot": "Bot",
"account.badges.group": "Grup",
"account.block": "Bloqueja @{name}",
"account.block_domain": "Amaga-ho tot de {domain}",
"account.blocked": "Bloquejat",
"account.browse_more_on_origin_server": "Navega més en el perfil original",
"account.cancel_follow_request": "Anul·la la sol·licitud de seguiment",
"account.direct": "Missatge directe @{name}",
"account.domain_blocked": "Domini ocult",
@ -39,6 +42,10 @@
"account.unfollow": "Deixa de seguir",
"account.unmute": "Treure silenci de @{name}",
"account.unmute_notifications": "Activar notificacions de @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Si us plau torna-ho a provar després de {retry_time, time, medium}.",
"alert.rate_limited.title": "Límit de freqüència",
"alert.unexpected.message": "S'ha produït un error inesperat.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Mostra la configuració",
"column_header.unpin": "No fixis",
"column_subheading.settings": "Configuració",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Només local",
"community.column_settings.media_only": "Només multimèdia",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Només remot",
"compose_form.direct_message_warning": "Aquest tut només serà enviat als usuaris esmentats.",
"compose_form.direct_message_warning_learn_more": "Aprèn més",
"compose_form.hashtag_warning": "Aquesta tut no es mostrarà en cap etiqueta ja que no està llistat. Només els tuts públics poden ser cercats per etiqueta.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "respondre",
"keyboard_shortcuts.requests": "per a obrir la llista de sol·licituds de seguiment",
"keyboard_shortcuts.search": "per a centrar la cerca",
"keyboard_shortcuts.spoilers": "mostrar/amagar el camp CW",
"keyboard_shortcuts.start": "per a obrir la columna \"Començar\"",
"keyboard_shortcuts.toggle_hidden": "per a mostrar o amagar text sota CW",
"keyboard_shortcuts.toggle_sensitivity": "per a mostrar o amagar contingut multimèdia",
@ -348,7 +356,7 @@
"report.target": "Informes {target}",
"search.placeholder": "Cercar",
"search_popout.search_format": "Format de cerca avançada",
"search_popout.tips.full_text": "Text simple recupera publicacions que has escrit, les marcades com a favorites, les impulsades o en les que has estat esmentat, així com usuaris, noms d'usuari i etiquetes.",
"search_popout.tips.full_text": "Text simple recupera publicacions que has escrit, les marcades com a preferides, les impulsades o en les que has estat esmentat, així com usuaris, noms d'usuari i etiquetes.",
"search_popout.tips.hashtag": "etiqueta",
"search_popout.tips.status": "tut",
"search_popout.tips.text": "El text simple retorna coincidències amb els noms de visualització, els noms d'usuari i les etiquetes",
@ -364,7 +372,7 @@
"status.bookmark": "Marcador",
"status.cancel_reblog_private": "Desfer l'impuls",
"status.cannot_reblog": "Aquesta publicació no pot ser impulsada",
"status.copy": "Copia l'enllaç al tut",
"status.copy": "Copia l'enllaç a l'estat",
"status.delete": "Esborrar",
"status.detailed_status": "Visualització detallada de la conversa",
"status.direct": "Missatge directe @{name}",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minut} other {# minuts}} restants",
"time_remaining.moments": "Moments restants",
"time_remaining.seconds": "{number, plural, one {# segon} other {# segons}} restants",
"timeline_hint.remote_resource_not_displayed": "{resource} dels altres servidors no son mostrats.",
"timeline_hint.resources.followers": "Seguidors",
"timeline_hint.resources.follows": "Seguiments",
"timeline_hint.resources.statuses": "Tuts més antics",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} parlant-hi",
"trends.trending_now": "Ara en tendència",
"ui.beforeunload": "El teu esborrany es perdrà si surts de Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Aghjunghje o toglie da e liste",
"account.badges.bot": "Bot",
"account.badges.group": "Gruppu",
"account.block": "Bluccà @{name}",
"account.block_domain": "Piattà u duminiu {domain}",
"account.blocked": "Bluccatu",
"account.browse_more_on_origin_server": "Vede di più nant'à u prufile uriginale",
"account.cancel_follow_request": "Annullà a dumanda d'abbunamentu",
"account.direct": "Missaghju direttu @{name}",
"account.domain_blocked": "Duminiu piattatu",
@ -39,6 +42,10 @@
"account.unfollow": "Ùn siguità più",
"account.unmute": "Ùn piattà più @{name}",
"account.unmute_notifications": "Ùn piattà più nutificazione da @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Pruvate ancu dop'à {retry_time, time, medium}.",
"alert.rate_limited.title": "Ghjettu limitatu",
"alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Mustrà i parametri",
"column_header.unpin": "Spuntarulà",
"column_subheading.settings": "Parametri",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Solu lucale",
"community.column_settings.media_only": "Solu media",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Solu distante",
"compose_form.direct_message_warning": "Solu l'utilizatori mintuvati puderenu vede stu statutu.",
"compose_form.direct_message_warning_learn_more": "Amparà di più",
"compose_form.hashtag_warning": "Stu statutu ùn hè \"Micca listatu\" è ùn sarà micca listatu indè e circate da hashtag. Per esse vistu in quesse, u statutu deve esse \"Pubblicu\".",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "risponde",
"keyboard_shortcuts.requests": "per apre a lista di dumande d'abbunamentu",
"keyboard_shortcuts.search": "fucalizà nant'à l'area di circata",
"keyboard_shortcuts.spoilers": "per mustrà/piattà u campu CW",
"keyboard_shortcuts.start": "per apre a culonna \"per principià\"",
"keyboard_shortcuts.toggle_hidden": "vede/piattà u testu daretu à l'avertimentu CW",
"keyboard_shortcuts.toggle_sensitivity": "vede/piattà i media",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minuta ferma} other {# minute fermanu}} left",
"time_remaining.moments": "Ci fermanu qualchi mumentu",
"time_remaining.seconds": "{number, plural, one {# siconda ferma} other {# siconde fermanu}}",
"timeline_hint.remote_resource_not_displayed": "{resource} da l'altri servori ùn so micca affissati·e.",
"timeline_hint.resources.followers": "Abbunati",
"timeline_hint.resources.follows": "Abbunamenti",
"timeline_hint.resources.statuses": "Statuti più anziani",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu",
"trends.trending_now": "Tindenze d'avà",
"ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Přidat nebo odstranit ze seznamů",
"account.badges.bot": "Robot",
"account.badges.group": "Skupina",
"account.block": "Zablokovat uživatele @{name}",
"account.block_domain": "Skrýt vše ze serveru {domain}",
"account.blocked": "Blokováno",
"account.browse_more_on_origin_server": "Více na původním profilu",
"account.cancel_follow_request": "Zrušit žádost o sledování",
"account.direct": "Poslat uživateli @{name} přímou zprávu",
"account.domain_blocked": "Doména skryta",
@ -39,6 +42,10 @@
"account.unfollow": "Přestat sledovat",
"account.unmute": "Odkrýt uživatele @{name}",
"account.unmute_notifications": "Odkrýt oznámení od uživatele @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Zkuste to prosím znovu za {retry_time, time, medium}.",
"alert.rate_limited.title": "Rychlost omezena",
"alert.unexpected.message": "Objevila se neočekávaná chyba.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Zobrazit nastavení",
"column_header.unpin": "Odepnout",
"column_subheading.settings": "Nastavení",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Pouze místní",
"community.column_settings.media_only": "Pouze média",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Pouze vzdálené",
"compose_form.direct_message_warning": "Tento toot bude odeslán pouze zmíněným uživatelům.",
"compose_form.direct_message_warning_learn_more": "Zjistit více",
"compose_form.hashtag_warning": "Tento toot nebude zobrazen pod žádným hashtagem, neboť je neuvedený. Pouze veřejné tooty mohou být vyhledány podle hashtagu.",
@ -166,7 +173,7 @@
"errors.unexpected_crash.report_issue": "Nahlásit problém",
"follow_request.authorize": "Autorizovat",
"follow_request.reject": "Odmítnout",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
"follow_requests.unlocked_explanation": "Přestože váš účet není uzamčen, {domain} si myslí, že budete chtít následující požadavky na sledování zkontrolovat ručně.",
"getting_started.developers": "Vývojáři",
"getting_started.directory": "Adresář profilů",
"getting_started.documentation": "Dokumentace",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "odpovědět",
"keyboard_shortcuts.requests": "otevření seznamu požadavků o sledování",
"keyboard_shortcuts.search": "zaměření na hledání",
"keyboard_shortcuts.spoilers": "zobrazit/skrýt pole CW",
"keyboard_shortcuts.start": "otevření sloupce „začínáme“",
"keyboard_shortcuts.toggle_hidden": "zobrazení/skrytí textu za varováním o obsahu",
"keyboard_shortcuts.toggle_sensitivity": "zobrazení/skrytí médií",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {Zbývá # minuta} few {Zbývají # minuty} many {Zbývá # minut} other {Zbývá # minut}}",
"time_remaining.moments": "Zbývá několik sekund",
"time_remaining.seconds": "{number, plural, one {Zbývá # sekunda} few {Zbývají # sekundy} many {Zbývá # sekund} other {Zbývá # sekund}}",
"timeline_hint.remote_resource_not_displayed": "{resource} z jiných serveru se nezobrazuje.",
"timeline_hint.resources.followers": "Sledující",
"timeline_hint.resources.follows": "Sleduje",
"timeline_hint.resources.statuses": "Starší tooty",
"trends.count_by_accounts": "{count} {rawCount, plural, one {člověk} few {lidé} many {lidí} other {lidí}} hovoří",
"trends.trending_now": "Aktuální trendy",
"ui.beforeunload": "Pokud Mastodon opustíte, váš koncept se ztratí.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Ychwanegu neu Dileu o'r rhestrau",
"account.badges.bot": "Bot",
"account.badges.group": "Grŵp",
"account.block": "Blocio @{name}",
"account.block_domain": "Cuddio popeth rhag {domain}",
"account.blocked": "Blociwyd",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Canslo cais dilyn",
"account.direct": "Neges breifat @{name}",
"account.domain_blocked": "Parth wedi ei guddio",
@ -39,6 +42,10 @@
"account.unfollow": "Dad-ddilyn",
"account.unmute": "Dad-dawelu @{name}",
"account.unmute_notifications": "Dad-dawelu hysbysiadau o @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Ceisiwch eto ar ôl {retry_time, time, medium}.",
"alert.rate_limited.title": "Cyfradd gyfyngedig",
"alert.unexpected.message": "Digwyddodd gwall annisgwyl.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Dangos gosodiadau",
"column_header.unpin": "Dadbinio",
"column_subheading.settings": "Gosodiadau",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Lleol yn unig",
"community.column_settings.media_only": "Cyfryngau yn unig",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Anghysbell yn unig",
"compose_form.direct_message_warning": "Mi fydd y tŵt hwn ond yn cael ei anfon at y defnyddwyr sy'n cael eu crybwyll.",
"compose_form.direct_message_warning_learn_more": "Dysgu mwy",
"compose_form.hashtag_warning": "Ni fydd y tŵt hwn wedi ei restru o dan unrhyw hashnod gan ei fod heb ei restru. Dim ond tŵtiau cyhoeddus gellid chwilota amdanynt drwy hashnod.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "i ateb",
"keyboard_shortcuts.requests": "i agor rhestr ceisiadau dilyn",
"keyboard_shortcuts.search": "i ffocysu chwilio",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "i agor colofn \"dechrau arni\"",
"keyboard_shortcuts.toggle_hidden": "i ddangos/cuddio testun tu ôl i CW",
"keyboard_shortcuts.toggle_sensitivity": "i ddangos/gyddio cyfryngau",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# funud} other {# o funudau}} ar ôl",
"time_remaining.moments": "Munudau ar ôl",
"time_remaining.seconds": "{number, plural, one {# eiliad} other {# o eiliadau}} ar ôl",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} yn siarad",
"trends.trending_now": "Yn tueddu nawr",
"ui.beforeunload": "Mi fyddwch yn colli eich drafft os gadewch Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Tilføj eller fjern fra lister",
"account.badges.bot": "Robot",
"account.badges.group": "Gruppe",
"account.block": "Bloker @{name}",
"account.block_domain": "Skjul alt fra {domain}",
"account.blocked": "Blokeret",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Annullér følgeranmodning",
"account.direct": "Send en direkte besked til @{name}",
"account.domain_blocked": "Domænet er blevet skjult",
@ -39,6 +42,10 @@
"account.unfollow": "Følg ikke længere",
"account.unmute": "Fjern dæmpningen af @{name}",
"account.unmute_notifications": "Fjern dæmpningen af notifikationer fra @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Prøv venligst igen efter {retry_time, time, medium}.",
"alert.rate_limited.title": "Gradsbegrænset",
"alert.unexpected.message": "Der opstod en uventet fejl.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "for at svare",
"keyboard_shortcuts.requests": "for at åbne listen over følgeranmodninger",
"keyboard_shortcuts.search": "for at fokusere søgningen",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "for at åbne \"kom igen\" kolonnen",
"keyboard_shortcuts.toggle_hidden": "for at vise/skjule tekst bag CW",
"keyboard_shortcuts.toggle_sensitivity": "for at vise/skjule medier",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minut} other {# minutter}} tilbage",
"time_remaining.moments": "Få øjeblikke tilbage",
"time_remaining.seconds": "{number, plural, one {# sekund} other {# sekunder}} tilbage",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {personer}} snakker",
"trends.trending_now": "Hot lige nu",
"ui.beforeunload": "Din kladde vil gå tabt hvis du forlader Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Hinzufügen oder Entfernen von Listen",
"account.badges.bot": "Bot",
"account.badges.group": "Gruppe",
"account.block": "@{name} blockieren",
"account.block_domain": "Alles von {domain} blockieren",
"account.blocked": "Blockiert",
"account.browse_more_on_origin_server": "Mehr auf dem Originalprofil durchsuchen",
"account.cancel_follow_request": "Folgeanfrage abbrechen",
"account.direct": "Direktnachricht an @{name}",
"account.domain_blocked": "Domain versteckt",
@ -39,6 +42,10 @@
"account.unfollow": "Entfolgen",
"account.unmute": "@{name} nicht mehr stummschalten",
"account.unmute_notifications": "Benachrichtigungen von @{name} einschalten",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Bitte versuche es nach {retry_time, time, medium}.",
"alert.rate_limited.title": "Anfragelimit überschritten",
"alert.unexpected.message": "Ein unerwarteter Fehler ist aufgetreten.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Einstellungen anzeigen",
"column_header.unpin": "Lösen",
"column_subheading.settings": "Einstellungen",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Nur lokal",
"community.column_settings.media_only": "Nur Medien",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Nur entfernt",
"compose_form.direct_message_warning": "Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.",
"compose_form.direct_message_warning_learn_more": "Mehr erfahren",
"compose_form.hashtag_warning": "Dieser Beitrag wird nicht durch Hashtags entdeckbar sein, weil er ungelistet ist. Nur öffentliche Beiträge tauchen in Hashtag-Zeitleisten auf.",
@ -113,7 +120,7 @@
"confirmations.mute.explanation": "Dies wird Beiträge von dieser Person und Beiträge, die diese Person erwähnen, ausblenden, aber es wird der Person trotzdem erlauben, deine Beiträge zu sehen und dir zu folgen.",
"confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?",
"confirmations.redraft.confirm": "Löschen und neu erstellen",
"confirmations.redraft.message": "Bist du dir sicher, dass du diesen Beitrag löschen und neu erstellen möchtest? Favorisierungen, geteilte Beiträge und Antworten werden verloren gehen.",
"confirmations.redraft.message": "Bist du dir sicher, dass du diesen Tröt löschen und neu erstellen möchtest? Favs, geteilte Beiträge und Antworten werden verloren gehen.",
"confirmations.reply.confirm": "Antworten",
"confirmations.reply.message": "Wenn du jetzt antwortest wird es die gesamte Nachricht verwerfen, die du gerade schreibst. Möchtest du wirklich fortfahren?",
"confirmations.unfollow.confirm": "Entfolgen",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "antworten",
"keyboard_shortcuts.requests": "Liste der Folge-Anfragen öffnen",
"keyboard_shortcuts.search": "Suche fokussieren",
"keyboard_shortcuts.spoilers": "um CW-Feld anzuzeigen/auszublenden",
"keyboard_shortcuts.start": "\"Erste Schritte\"-Spalte öffnen",
"keyboard_shortcuts.toggle_hidden": "Text hinter einer Inhaltswarnung verstecken/anzeigen",
"keyboard_shortcuts.toggle_sensitivity": "Medien hinter einer Inhaltswarnung verstecken/anzeigen",
@ -348,7 +356,7 @@
"report.target": "{target} melden",
"search.placeholder": "Suche",
"search_popout.search_format": "Fortgeschrittenes Suchformat",
"search_popout.tips.full_text": "Einfache Texteingabe gibt Beiträge, die du geschrieben, favorisiert und geteilt hast zurück. Außerdem auch Beiträge in denen du erwähnt wurdest, aber auch passende Nutzernamen, Anzeigenamen oder Hashtags.",
"search_popout.tips.full_text": "Einfache Texteingabe gibt Tröts, die du geschrieben, gefavt und geteilt hast zurück. Außerdem auch Tröts, in denen du erwähnt wurdest, aber auch passende Nutzernamen, Anzeigenamen, oder Hashtags.",
"search_popout.tips.hashtag": "Hashtag",
"search_popout.tips.status": "Beitrag",
"search_popout.tips.text": "Einfache Texteingabe gibt Anzeigenamen, Benutzernamen und Hashtags zurück",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# Minute} other {# Minuten}} verbleibend",
"time_remaining.moments": "Schließt in Kürze",
"time_remaining.seconds": "{number, plural, one {# Sekunde} other {# Sekunden}} verbleibend",
"timeline_hint.remote_resource_not_displayed": "{resource} von anderen Servern werden nicht angezeigt.",
"timeline_hint.resources.followers": "Follower",
"timeline_hint.resources.follows": "Folgt",
"timeline_hint.resources.statuses": "Ältere Toots",
"trends.count_by_accounts": "{count} {rawCount, plural, eine {Person} other {Personen}} reden darüber",
"trends.trending_now": "In den Trends",
"ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",

View File

@ -139,6 +139,23 @@
],
"path": "app/javascript/mastodon/components/column_header.json"
},
{
"descriptors": [
{
"defaultMessage": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
"id": "account.statuses_counter"
},
{
"defaultMessage": "{count, plural, other {{counter} Following}}",
"id": "account.following_counter"
},
{
"defaultMessage": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
"id": "account.followers_counter"
}
],
"path": "app/javascript/mastodon/components/common_counter.json"
},
{
"descriptors": [
{
@ -172,8 +189,8 @@
{
"descriptors": [
{
"defaultMessage": "{count} {rawCount, plural, one {person} other {people}} talking",
"id": "trends.count_by_accounts"
"defaultMessage": "{count, plural, one {{counter} person} other {{counter} people}} talking",
"id": "trends.counter_by_accounts"
}
],
"path": "app/javascript/mastodon/components/hashtag.json"
@ -340,6 +357,23 @@
],
"path": "app/javascript/mastodon/components/relative_timestamp.json"
},
{
"descriptors": [
{
"defaultMessage": "{count}K",
"id": "units.short.thousand"
},
{
"defaultMessage": "{count}M",
"id": "units.short.million"
},
{
"defaultMessage": "{count}B",
"id": "units.short.billion"
}
],
"path": "app/javascript/mastodon/components/short_number.json"
},
{
"descriptors": [
{
@ -492,6 +526,22 @@
},
{
"descriptors": [
{
"defaultMessage": "Public",
"id": "privacy.public.short"
},
{
"defaultMessage": "Unlisted",
"id": "privacy.unlisted.short"
},
{
"defaultMessage": "Followers-only",
"id": "privacy.private.short"
},
{
"defaultMessage": "Direct",
"id": "privacy.direct.short"
},
{
"defaultMessage": "Filtered",
"id": "status.filtered"
@ -507,6 +557,19 @@
],
"path": "app/javascript/mastodon/components/status.json"
},
{
"descriptors": [
{
"defaultMessage": "{resource} from other servers are not displayed.",
"id": "timeline_hint.remote_resource_not_displayed"
},
{
"defaultMessage": "Browse more on the original profile",
"id": "account.browse_more_on_origin_server"
}
],
"path": "app/javascript/mastodon/components/timeline_hint.json"
},
{
"descriptors": [
{
@ -619,6 +682,10 @@
},
{
"descriptors": [
{
"defaultMessage": "Older toots",
"id": "timeline_hint.resources.statuses"
},
{
"defaultMessage": "Profile unavailable",
"id": "empty_column.account_unavailable"
@ -630,6 +697,23 @@
],
"path": "app/javascript/mastodon/features/account_timeline/index.json"
},
{
"descriptors": [
{
"defaultMessage": "Click to add a note",
"id": "account_note.placeholder"
},
{
"defaultMessage": "Saved",
"id": "generic.saved"
},
{
"defaultMessage": "Note",
"id": "account.account_note_header"
}
],
"path": "app/javascript/mastodon/features/account/components/account_note.json"
},
{
"descriptors": [
{
@ -783,18 +867,6 @@
{
"defaultMessage": "Group",
"id": "account.badges.group"
},
{
"defaultMessage": "Toots",
"id": "account.posts"
},
{
"defaultMessage": "Follows",
"id": "account.follows"
},
{
"defaultMessage": "Followers",
"id": "account.followers"
}
],
"path": "app/javascript/mastodon/features/account/components/header.json"
@ -1189,7 +1261,7 @@
{
"descriptors": [
{
"defaultMessage": "Add media ({formats})",
"defaultMessage": "Add images, a video or an audio file",
"id": "upload_button.label"
}
],
@ -1542,6 +1614,10 @@
},
{
"descriptors": [
{
"defaultMessage": "Followers",
"id": "timeline_hint.resources.followers"
},
{
"defaultMessage": "Profile unavailable",
"id": "empty_column.account_unavailable"
@ -1555,6 +1631,10 @@
},
{
"descriptors": [
{
"defaultMessage": "Follows",
"id": "timeline_hint.resources.follows"
},
{
"defaultMessage": "Profile unavailable",
"id": "empty_column.account_unavailable"
@ -1920,6 +2000,10 @@
"defaultMessage": "to start a brand new toot",
"id": "keyboard_shortcuts.toot"
},
{
"defaultMessage": "to show/hide CW field",
"id": "keyboard_shortcuts.spoilers"
},
{
"defaultMessage": "to navigate back",
"id": "keyboard_shortcuts.back"
@ -2427,6 +2511,36 @@
],
"path": "app/javascript/mastodon/features/status/components/action_bar.json"
},
{
"descriptors": [
{
"defaultMessage": "Sensitive content",
"id": "status.sensitive_warning"
}
],
"path": "app/javascript/mastodon/features/status/components/card.json"
},
{
"descriptors": [
{
"defaultMessage": "Public",
"id": "privacy.public.short"
},
{
"defaultMessage": "Unlisted",
"id": "privacy.unlisted.short"
},
{
"defaultMessage": "Followers-only",
"id": "privacy.private.short"
},
{
"defaultMessage": "Direct",
"id": "privacy.direct.short"
}
],
"path": "app/javascript/mastodon/features/status/components/detailed_status.json"
},
{
"descriptors": [
{
@ -2954,10 +3068,6 @@
"defaultMessage": "Exit full screen",
"id": "video.exit_fullscreen"
},
{
"defaultMessage": "Download file",
"id": "video.download"
},
{
"defaultMessage": "Sensitive content",
"id": "status.sensitive_warning"
@ -2969,4 +3079,4 @@
],
"path": "app/javascript/mastodon/features/video/index.json"
}
]
]

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες",
"account.badges.bot": "Μποτ",
"account.badges.group": "Ομάδα",
"account.block": "Αποκλεισμός @{name}",
"account.block_domain": "Απόκρυψη όλων από {domain}",
"account.blocked": "Αποκλεισμένος/η",
"account.browse_more_on_origin_server": "Δες περισσότερα στο αρχικό προφίλ",
"account.cancel_follow_request": "Ακύρωση αιτήματος παρακολούθησης",
"account.direct": "Προσωπικό μήνυμα προς @{name}",
"account.domain_blocked": "Κρυμμένος τομέας",
@ -39,6 +42,10 @@
"account.unfollow": "Διακοπή παρακολούθησης",
"account.unmute": "Διακοπή αποσιώπησης @{name}",
"account.unmute_notifications": "Διακοπή αποσιώπησης ειδοποιήσεων του/της @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Παρακαλούμε δοκίμασε ξανά αφού περάσει η {retry_time, time, medium}.",
"alert.rate_limited.title": "Περιορισμός συχνότητας",
"alert.unexpected.message": "Προέκυψε απροσδόκητο σφάλμα.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Εμφάνιση ρυθμίσεων",
"column_header.unpin": "Ξεκαρφίτσωμα",
"column_subheading.settings": "Ρυθμίσεις",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Τοπικά μόνο",
"community.column_settings.media_only": "Μόνο πολυμέσα",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Απομακρυσμένα μόνο",
"compose_form.direct_message_warning": "Αυτό το τουτ θα σταλεί μόνο στους αναφερόμενους χρήστες.",
"compose_form.direct_message_warning_learn_more": "Μάθετε περισσότερα",
"compose_form.hashtag_warning": "Αυτό το τουτ δεν θα εμφανίζεται κάτω από κανένα hashtag καθώς είναι αφανές. Μόνο τα δημόσια τουτ μπορούν να αναζητηθούν ανά hashtag.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "απάντηση",
"keyboard_shortcuts.requests": "άνοιγμα λίστας αιτημάτων παρακολούθησης",
"keyboard_shortcuts.search": "εστίαση αναζήτησης",
"keyboard_shortcuts.spoilers": "εμφάνιση/απόκρυψη πεδίου CW",
"keyboard_shortcuts.start": "άνοιγμα κολώνας \"Ξεκινώντας\"",
"keyboard_shortcuts.toggle_hidden": "εμφάνιση/απόκρυψη κειμένου πίσω από την προειδοποίηση",
"keyboard_shortcuts.toggle_sensitivity": "εμφάνιση/απόκρυψη πολυμέσων",
@ -412,6 +420,10 @@
"time_remaining.minutes": "απομένουν {number, plural, one {# λεπτό} other {# λεπτά}}",
"time_remaining.moments": "Απομένουν στιγμές",
"time_remaining.seconds": "απομένουν {number, plural, one {# δευτερόλεπτο} other {# δευτερόλεπτα}}",
"timeline_hint.remote_resource_not_displayed": "{resource} από άλλους διακομιστές δεν εμφανίζονται.",
"timeline_hint.resources.followers": "Ακόλουθοι",
"timeline_hint.resources.follows": "Ακολουθεί",
"timeline_hint.resources.statuses": "Παλαιότερα τουτ",
"trends.count_by_accounts": "{count} {rawCount, plural, one {άτομο μιλάει} other {άτομα μιλάνε}}",
"trends.trending_now": "Δημοφιλή τώρα",
"ui.beforeunload": "Το προσχέδιό σου θα χαθεί αν φύγεις από το Mastodon.",

View File

@ -1,10 +1,12 @@
{
"account.account_note_header": "Note",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot",
"account.badges.group": "Group",
"account.block": "Block @{name}",
"account.block_domain": "Block domain {domain}",
"account.blocked": "Blocked",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Direct message @{name}",
"account.domain_blocked": "Domain blocked",
@ -13,7 +15,8 @@
"account.follow": "Follow",
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.follows": "Follows",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
"account.following_counter": "{count, plural, other {{counter} Following}}",
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.follows_you": "Follows you",
"account.hide_reblogs": "Hide boosts from @{name}",
@ -33,12 +36,14 @@
"account.requested": "Awaiting approval. Click to cancel follow request",
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show boosts from @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unblock domain {domain}",
"account.unendorse": "Don't feature on profile",
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_note.placeholder": "Click to add note",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "An unexpected error occurred.",
@ -102,7 +107,7 @@
"confirmations.block.confirm": "Block",
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.delete.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this status?",
"confirmations.delete.message": "Are you sure you want to delete this toot?",
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.domain_block.confirm": "Block entire domain",
@ -113,7 +118,7 @@
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.redraft.message": "Are you sure you want to delete this toot and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.reply.confirm": "Reply",
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.unfollow.confirm": "Unfollow",
@ -126,7 +131,7 @@
"directory.local": "From {domain} only",
"directory.new_arrivals": "New arrivals",
"directory.recently_active": "Recently active",
"embed.instructions": "Embed this status on your website by copying the code below.",
"embed.instructions": "Embed this toot on your website by copying the code below.",
"embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.custom": "Custom",
@ -155,7 +160,7 @@
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
"empty_column.home.public_timeline": "the public timeline",
"empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
"empty_column.list": "There is nothing in this list yet. When members of this list post new toots, they will appear here.",
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
@ -167,6 +172,7 @@
"follow_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
"generic.saved": "Saved",
"getting_started.developers": "Developers",
"getting_started.directory": "Profile directory",
"getting_started.documentation": "Documentation",
@ -212,12 +218,12 @@
"keyboard_shortcuts.back": "to navigate back",
"keyboard_shortcuts.blocked": "to open blocked users list",
"keyboard_shortcuts.boost": "to boost",
"keyboard_shortcuts.column": "to focus a status in one of the columns",
"keyboard_shortcuts.column": "to focus a toot in one of the columns",
"keyboard_shortcuts.compose": "to focus the compose textarea",
"keyboard_shortcuts.description": "Description",
"keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "to move down in the list",
"keyboard_shortcuts.enter": "to open status",
"keyboard_shortcuts.enter": "to open toot",
"keyboard_shortcuts.favourite": "to favourite",
"keyboard_shortcuts.favourites": "to open favourites list",
"keyboard_shortcuts.federated": "to open federated timeline",
@ -236,6 +242,7 @@
"keyboard_shortcuts.reply": "to reply",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -284,13 +291,13 @@
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.security": "Security",
"notification.favourite": "{name} favourited your status",
"notification.favourite": "{name} favourited your toot",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you",
"notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your status",
"notification.reblog": "{name} boosted your toot",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications",
@ -321,7 +328,7 @@
"poll.voted": "You voted for this answer",
"poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll",
"privacy.change": "Adjust status privacy",
"privacy.change": "Adjust toot privacy",
"privacy.direct.long": "Visible for mentioned users only",
"privacy.direct.short": "Direct",
"privacy.private.long": "Visible for followers only",
@ -348,9 +355,9 @@
"report.target": "Reporting {target}",
"search.placeholder": "Search",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.full_text": "Simple text returns toots you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.status": "toot",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.accounts": "People",
@ -359,12 +366,12 @@
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this status in the moderation interface",
"status.admin_status": "Open this toot in the moderation interface",
"status.block": "Block @{name}",
"status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to status",
"status.copy": "Copy link to toot",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
@ -377,7 +384,7 @@
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
"status.open": "Expand this toot",
"status.pin": "Pin on profile",
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
@ -412,11 +419,18 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"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",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking",
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"units.short.billion": "{count}B",
"units.short.million": "{count}M",
"units.short.thousand": "{count}K",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Aldoni al aŭ forigi el listoj",
"account.badges.bot": "Roboto",
"account.badges.group": "Grupo",
"account.block": "Bloki @{name}",
"account.block_domain": "Kaŝi ĉion de {domain}",
"account.blocked": "Blokita",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Nuligi peton de sekvado",
"account.direct": "Rekte mesaĝi @{name}",
"account.domain_blocked": "Domajno kaŝita",
@ -39,6 +42,10 @@
"account.unfollow": "Ne plu sekvi",
"account.unmute": "Malsilentigi @{name}",
"account.unmute_notifications": "Malsilentigi sciigojn de @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Bonvolu reprovi post {retry_time, time, medium}.",
"alert.rate_limited.title": "Mesaĝkvante limigita",
"alert.unexpected.message": "Neatendita eraro okazis.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Montri agordojn",
"column_header.unpin": "Depingli",
"column_subheading.settings": "Agordado",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Nur loka",
"community.column_settings.media_only": "Nur aŭdovidaĵoj",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Nur malproksima",
"compose_form.direct_message_warning": "Tiu mesaĝo estos sendita nur al menciitaj uzantoj.",
"compose_form.direct_message_warning_learn_more": "Lerni pli",
"compose_form.hashtag_warning": "Ĉi tiu mesaĝo ne estos listigita per ajna kradvorto. Nur publikaj mesaĝoj estas serĉeblaj per kradvortoj.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "respondi",
"keyboard_shortcuts.requests": "malfermi la liston de petoj de sekvado",
"keyboard_shortcuts.search": "enfokusigi la serĉilon",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "malfermi la kolumnon «por komenci»",
"keyboard_shortcuts.toggle_hidden": "montri/kaŝi tekston malantaŭ enhava averto",
"keyboard_shortcuts.toggle_sensitivity": "montri/kaŝi aŭdovidaĵojn",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minuto} other {# minutoj}} restas",
"time_remaining.moments": "Momenteto restas",
"time_remaining.seconds": "{number, plural, one {# sekundo} other {# sekundoj}} restas",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persono} other {personoj}} parolas",
"trends.trending_now": "Nunaj furoraĵoj",
"ui.beforeunload": "Via malneto perdiĝos se vi eliras de Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Agregar o quitar de las listas",
"account.badges.bot": "Bot",
"account.badges.group": "Grupo",
"account.block": "Bloquear a @{name}",
"account.block_domain": "Ocultar todo de {domain}",
"account.blocked": "Bloqueado",
"account.browse_more_on_origin_server": "Explorar más en el perfil original",
"account.cancel_follow_request": "Cancelar la solicitud de seguimiento",
"account.direct": "Mensaje directo a @{name}",
"account.domain_blocked": "Dominio oculto",
@ -39,6 +42,10 @@
"account.unfollow": "Dejar de seguir",
"account.unmute": "Dejar de silenciar a @{name}",
"account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Por favor, reintentá después de las {retry_time, time, medium}.",
"alert.rate_limited.title": "Tarifa limitada",
"alert.unexpected.message": "Ocurrió un error.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Mostrar configuración",
"column_header.unpin": "Dejar de fijar",
"column_subheading.settings": "Configuración",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Sólo local",
"community.column_settings.media_only": "Sólo medios",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Sólo remoto",
"compose_form.direct_message_warning": "Este toot sólo será enviado a los usuarios mencionados.",
"compose_form.direct_message_warning_learn_more": "Aprendé más",
"compose_form.hashtag_warning": "Este toot no se mostrará bajo hashtags porque no es público. Sólo los toots públicos se pueden buscar por hashtag.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "para responder",
"keyboard_shortcuts.requests": "para abrir la lista de solicitudes de seguimiento",
"keyboard_shortcuts.search": "para enfocar la búsqueda",
"keyboard_shortcuts.spoilers": "para mostrar/ocultar el campo \"CW\"",
"keyboard_shortcuts.start": "para abrir la columna \"Introducción\"",
"keyboard_shortcuts.toggle_hidden": "para mostrar/ocultar el texto detrás de la advertencia de contenido",
"keyboard_shortcuts.toggle_sensitivity": "para mostrar/ocultar los medios",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural,one {queda # minuto} other {quedan # minutos}}",
"time_remaining.moments": "Momentos restantes",
"time_remaining.seconds": "{number, plural,one {queda # segundo} other {quedan # segundos}}",
"timeline_hint.remote_resource_not_displayed": "{resource} de otros servidores no se muestran.",
"timeline_hint.resources.followers": "Seguidores",
"timeline_hint.resources.follows": "Siguiendo",
"timeline_hint.resources.statuses": "Toots antiguos",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {personas}} hablando",
"trends.trending_now": "Tendencia ahora",
"ui.beforeunload": "Tu borrador se perderá si abandonás Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Agregar o eliminar de listas",
"account.badges.bot": "Bot",
"account.badges.group": "Grupo",
"account.block": "Bloquear a @{name}",
"account.block_domain": "Ocultar todo de {domain}",
"account.blocked": "Bloqueado",
"account.browse_more_on_origin_server": "Ver más en el perfil original",
"account.cancel_follow_request": "Cancelar la solicitud de seguimiento",
"account.direct": "Mensaje directo a @{name}",
"account.domain_blocked": "Dominio oculto",
@ -39,6 +42,10 @@
"account.unfollow": "Dejar de seguir",
"account.unmute": "Dejar de silenciar a @{name}",
"account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Por favor reintente después de {retry_time, time, medium}.",
"alert.rate_limited.title": "Tarifa limitada",
"alert.unexpected.message": "Hubo un error inesperado.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Mostrar ajustes",
"column_header.unpin": "Dejar de fijar",
"column_subheading.settings": "Ajustes",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Solo local",
"community.column_settings.media_only": "Solo media",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Solo remoto",
"compose_form.direct_message_warning": "Este toot solo será enviado a los usuarios mencionados.",
"compose_form.direct_message_warning_learn_more": "Aprender mas",
"compose_form.hashtag_warning": "Este toot no se mostrará bajo hashtags porque no es público. Sólo los toots públicos se pueden buscar por hashtag.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "para responder",
"keyboard_shortcuts.requests": "abrir la lista de peticiones de seguidores",
"keyboard_shortcuts.search": "para poner el foco en la búsqueda",
"keyboard_shortcuts.spoilers": "para mostrar/ocultar el campo CW",
"keyboard_shortcuts.start": "abrir la columna \"comenzar\"",
"keyboard_shortcuts.toggle_hidden": "mostrar/ocultar texto tras aviso de contenido (CW)",
"keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar medios",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minuto restante} other {# minutos restantes}}",
"time_remaining.moments": "Momentos restantes",
"time_remaining.seconds": "{number, plural, one {# segundo restante} other {# segundos restantes}}",
"timeline_hint.remote_resource_not_displayed": "{resource} de otros servidores no se muestran.",
"timeline_hint.resources.followers": "Seguidores",
"timeline_hint.resources.follows": "Seguidos",
"timeline_hint.resources.statuses": "Toots más antiguos",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {personas}} hablando",
"trends.trending_now": "Tendencia ahora",
"ui.beforeunload": "Tu borrador se perderá si sales de Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Lisa või Eemalda nimekirjadest",
"account.badges.bot": "Robot",
"account.badges.group": "Grupp",
"account.block": "Blokeeri @{name}",
"account.block_domain": "Peida kõik domeenist {domain}",
"account.blocked": "Blokeeritud",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Tühista jälgimistaotlus",
"account.direct": "Otsesõnum @{name}",
"account.domain_blocked": "Domeen peidetud",
@ -39,6 +42,10 @@
"account.unfollow": "Ära jälgi",
"account.unmute": "Ära vaigista @{name}",
"account.unmute_notifications": "Ära vaigista teateid kasutajalt @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Palun proovi uuesti pärast {retry_time, time, medium}.",
"alert.rate_limited.title": "Piiratud",
"alert.unexpected.message": "Tekkis ootamatu viga.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Näita sätteid",
"column_header.unpin": "Eemalda kinnitus",
"column_subheading.settings": "Sätted",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Ainult kohalik",
"community.column_settings.media_only": "Ainult meedia",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Ainult kaug",
"compose_form.direct_message_warning": "See tuut saadetakse ainult mainitud kasutajatele.",
"compose_form.direct_message_warning_learn_more": "Vaata veel",
"compose_form.hashtag_warning": "Seda tuuti ei kuvata ühegi sildi all, sest see on kirjendamata. Ainult avalikud tuutid on sildi järgi otsitavad.",
@ -166,7 +173,7 @@
"errors.unexpected_crash.report_issue": "Teavita veast",
"follow_request.authorize": "Autoriseeri",
"follow_request.reject": "Hülga",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
"follow_requests.unlocked_explanation": "Kuigi Teie konto pole lukustatud, soovitab {domain} personal siiski manuaalselt üle vaadata jälgimistaotlused nendelt kontodelt.",
"getting_started.developers": "Arendajad",
"getting_started.directory": "Profiili kataloog",
"getting_started.documentation": "Dokumentatsioon",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "vastamiseks",
"keyboard_shortcuts.requests": "avamaks jälgimistaotluste nimistut",
"keyboard_shortcuts.search": "otsingu fokuseerimiseks",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "avamaks \"Alusta\" tulpa",
"keyboard_shortcuts.toggle_hidden": "näitamaks/peitmaks teksti CW taga",
"keyboard_shortcuts.toggle_sensitivity": "et peita/näidata meediat",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minut} other {# minutit}} left",
"time_remaining.moments": "Hetked jäänud",
"time_remaining.seconds": "{number, plural, one {# sekund} other {# sekundit}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {inimene} other {inimesed}} talking",
"trends.trending_now": "Praegu populaarne",
"ui.beforeunload": "Teie mustand läheb kaotsi, kui lahkute Mastodonist.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Gehitu edo kendu zerrendetatik",
"account.badges.bot": "Bot-a",
"account.badges.group": "Taldea",
"account.block": "Blokeatu @{name}",
"account.block_domain": "Ezkutatu {domain} domeinuko guztia",
"account.blocked": "Blokeatuta",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Ezeztatu jarraitzeko eskaria",
"account.direct": "Mezu zuzena @{name}(r)i",
"account.domain_blocked": "Ezkutatutako domeinua",
@ -39,6 +42,10 @@
"account.unfollow": "Utzi jarraitzeari",
"account.unmute": "Desmututu @{name}",
"account.unmute_notifications": "Desmututu @{name}(r)en jakinarazpenak",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Saiatu {retry_time, time, medium} barru.",
"alert.rate_limited.title": "Abiadura mugatua",
"alert.unexpected.message": "Ustekabeko errore bat gertatu da.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Erakutsi ezarpenak",
"column_header.unpin": "Desfinkatu",
"column_subheading.settings": "Ezarpenak",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Lokala soilik",
"community.column_settings.media_only": "Multimedia besterik ez",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Urrunekoa soilik",
"compose_form.direct_message_warning": "Toot hau aipatutako erabiltzaileei besterik ez zaie bidaliko.",
"compose_form.direct_message_warning_learn_more": "Ikasi gehiago",
"compose_form.hashtag_warning": "Toot hau ez da traoletan agertuko zerrendatu gabekoa baita. Traoletan toot publikoak besterik ez dira agertzen.",
@ -166,7 +173,7 @@
"errors.unexpected_crash.report_issue": "Eman arazoaren berri",
"follow_request.authorize": "Baimendu",
"follow_request.reject": "Ukatu",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
"follow_requests.unlocked_explanation": "Zure kontua blokeatuta ez badago ere, {domain} domeinuko arduradunek uste dute kontu hauetako jarraipen eskariak agian eskuz begiratu nahiko dituzula.",
"getting_started.developers": "Garatzaileak",
"getting_started.directory": "Profil-direktorioa",
"getting_started.documentation": "Dokumentazioa",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "erantzutea",
"keyboard_shortcuts.requests": "jarraitzeko eskarien zerrenda irekitzeko",
"keyboard_shortcuts.search": "bilaketan fokua jartzea",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "\"Menua\" zutabea irekitzeko",
"keyboard_shortcuts.toggle_hidden": "testua erakustea/ezkutatzea abisu baten atzean",
"keyboard_shortcuts.toggle_sensitivity": "multimedia erakutsi/ezkutatzeko",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {minutu #} other {# minutu}} amaitzeko",
"time_remaining.moments": "Amaitzekotan",
"time_remaining.seconds": "{number, plural, one {segundo #} other {# segundo}} amaitzeko",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {pertsona} other {pertsona}} hitz egiten",
"trends.trending_now": "Joera orain",
"ui.beforeunload": "Zure zirriborroa galduko da Mastodon uzten baduzu.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "افزودن یا برداشتن از فهرست‌ها",
"account.badges.bot": "ربات",
"account.badges.group": "گروه",
"account.block": "مسدودسازی @{name}",
"account.block_domain": "نهفتن همه چیز از {domain}",
"account.blocked": "مسدود",
"account.browse_more_on_origin_server": "مرور بیش‌تر روی نمایهٔ اصلی",
"account.cancel_follow_request": "لغو درخواست پیگیری",
"account.direct": "پیام خصوصی به @{name}",
"account.domain_blocked": "دامنهٔ نهفته",
@ -27,7 +30,7 @@
"account.mute_notifications": "خموشاندن اعلان‌ها از @{name}",
"account.muted": "خموش",
"account.never_active": "هرگز",
"account.posts": "نوشته‌ها",
"account.posts": "بوق",
"account.posts_with_replies": "نوشته‌ها و پاسخ‌ها",
"account.report": "گزارش @{name}",
"account.requested": "منتظر پذیرش. برای لغو درخواست پی‌گیری کلیک کنید",
@ -39,6 +42,10 @@
"account.unfollow": "پایان پیگیری",
"account.unmute": "رفع خموشی @{name}",
"account.unmute_notifications": "رفع خموشی اعلان‌ها از @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "لطفاً پس از {retry_time, time, medium} دوباره بیازمایید.",
"alert.rate_limited.title": "محدودیت تعداد",
"alert.unexpected.message": "خطایی غیرمنتظره رخ داد.",
@ -74,9 +81,9 @@
"column_header.show_settings": "نمایش تنظیمات",
"column_header.unpin": "رهاکردن",
"column_subheading.settings": "تنظیمات",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "تنها محلّی",
"community.column_settings.media_only": "فقط رسانه",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "تنها دوردست",
"compose_form.direct_message_warning": "این بوق تنها به کاربرانی که از آن‌ها نام برده شده فرستاده خواهد شد.",
"compose_form.direct_message_warning_learn_more": "بیشتر بدانید",
"compose_form.hashtag_warning": "از آن‌جا که این بوق فهرست‌نشده است، در نتایج جست‌وجوی هشتگ‌ها پیدا نخواهد شد. تنها بوق‌های عمومی را می‌توان با جست‌وجوی هشتگ یافت.",
@ -107,7 +114,7 @@
"confirmations.delete_list.message": "مطمئنید می‌خواهید این فهرست را برای همیشه پاک کنید؟",
"confirmations.domain_block.confirm": "نهفتن تمام دامنه",
"confirmations.domain_block.message": "آیا جدی جدی می‌خواهید تمام دامنهٔ {domain} را مسدود کنید؟ در بیشتر موارد مسدودسازی یا خموشاندن چند حساب خاص کافی است و توصیه می‌شود. پس از این کار شما هیچ نوشته‌ای را از این دامنه در فهرست نوشته‌های عمومی یا اعلان‌هایتان نخواهید دید. پیگیرانتان از این دامنه هم حذف خواهند شد.",
"confirmations.logout.confirm": "خروج",
"confirmations.logout.confirm": "خروج از حساب",
"confirmations.logout.message": "مطمئنید می‌خواهید خارج شوید؟",
"confirmations.mute.confirm": "خموشاندن",
"confirmations.mute.explanation": "این کار فرسته‌های آن‌ها و فرسته‌هایی را که از آن‌ها نام برده پنهان می‌کند، ولی آن‌ها همچنان اجازه دارند فرسته‌های شما را ببینند و شما را پی بگیرند.",
@ -143,7 +150,7 @@
"emoji_button.symbols": "نمادها",
"emoji_button.travel": "سفر و مکان",
"empty_column.account_timeline": "هیچ بوقی این‌جا نیست!",
"empty_column.account_unavailable": "نمایهٔ ناموجود",
"empty_column.account_unavailable": "نمایهٔ موجود نیست",
"empty_column.blocks": "هنوز کسی را مسدود نکرده‌اید.",
"empty_column.bookmarked_statuses": "هنوز هیچ بوق نشان‌شده‌ای ندارید. وقتی بوقی را نشان‌کنید، این‌جا دیده خواهد شد.",
"empty_column.community": "فهرست نوشته‌های محلی خالی است. چیزی بنویسید تا چرخش بچرخد!",
@ -166,9 +173,9 @@
"errors.unexpected_crash.report_issue": "گزارش مشکل",
"follow_request.authorize": "اجازه دهید",
"follow_request.reject": "رد کنید",
"follow_requests.unlocked_explanation": "با یان که حسابتان قفل نیست، کارکنان {domain} فکر کردند که ممکن است بخواهید درخواست‌ها از این حساب‌ها را به صورت دستی بازبینی کنید.",
"follow_requests.unlocked_explanation": "با این که حسابتان قفل نیست، کارکنان {domain} فکر کردند که ممکن است بخواهید درخواست‌ها از این حساب‌ها را به صورت دستی بازبینی کنید.",
"getting_started.developers": "توسعه‌دهندگان",
"getting_started.directory": "فهرست گزیدهٔ کاربران",
"getting_started.directory": "فهرست نمایه",
"getting_started.documentation": "مستندات",
"getting_started.heading": "آغاز کنید",
"getting_started.invite": "دعوت از دیگران",
@ -179,7 +186,7 @@
"hashtag.column_header.tag_mode.any": "یا {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": "هیچ‌کدام از این‌ها",
@ -217,14 +224,14 @@
"keyboard_shortcuts.description": "توضیح",
"keyboard_shortcuts.direct": "برای گشودن ستون پیغام‌های مستقیم",
"keyboard_shortcuts.down": "برای پایین رفتن در فهرست",
"keyboard_shortcuts.enter": "برای گشودن نوشته",
"keyboard_shortcuts.enter": "برای گشودن وضعیت",
"keyboard_shortcuts.favourite": "برای پسندیدن",
"keyboard_shortcuts.favourites": "برای گشودن فهرست پسندیده‌ها",
"keyboard_shortcuts.federated": "برای گشودن فهرست نوشته‌های همه‌جا",
"keyboard_shortcuts.heading": "میان‌برهای صفحه‌کلید",
"keyboard_shortcuts.home": "برای گشودن ستون اصلی پیگیری‌ها",
"keyboard_shortcuts.hotkey": "میان‌بر",
"keyboard_shortcuts.legend": "برای نمایش این راهنما",
"keyboard_shortcuts.legend": "برای نمایش این نشانه",
"keyboard_shortcuts.local": "برای گشودن فهرست نوشته‌های محلی",
"keyboard_shortcuts.mention": "برای نام‌بردن از نویسنده",
"keyboard_shortcuts.muted": "برای گشودن فهرست کاربران خموش",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "برای پاسخ",
"keyboard_shortcuts.requests": "برای گشودن فهرست درخواست‌های پیگیری",
"keyboard_shortcuts.search": "برای تمرکز روی جستجو",
"keyboard_shortcuts.spoilers": "نمایش/نهفتن زمینهٔ هشدار محتوا",
"keyboard_shortcuts.start": "برای گشودن ستون «آغاز کنید»",
"keyboard_shortcuts.toggle_hidden": "برای نمایش/نهفتن نوشتهٔ پشت هشدار محتوا",
"keyboard_shortcuts.toggle_sensitivity": "برای نمایش/نهفتن رسانه",
@ -284,13 +292,13 @@
"navigation_bar.preferences": "ترجیحات",
"navigation_bar.public_timeline": "نوشته‌های همه‌جا",
"navigation_bar.security": "امنیت",
"notification.favourite": "{name} نوشتهٔ شما را پسندید",
"notification.favourite": "{name} وضعیتتان را برگزید",
"notification.follow": "{name} پیگیرتان شد",
"notification.follow_request": "{name} می‌خواهد پیگیر شما باشد",
"notification.mention": "{name} از شما نام برد",
"notification.own_poll": "نظرسنجی شما به پایان رسید",
"notification.poll": "نظرسنجی‌ای که در آن رأی دادید به پایان رسیده است",
"notification.reblog": "{name} نوشتهٔ شما را بازبوقید",
"notification.reblog": "{name} وضعیتتان را تقویت کرد",
"notifications.clear": "پاک‌کردن اعلان‌ها",
"notifications.clear_confirmation": "مطمئنید می‌خواهید همهٔ اعلان‌هایتان را برای همیشه پاک کنید؟",
"notifications.column_settings.alert": "اعلان‌های میزکار",
@ -348,13 +356,13 @@
"report.target": "در حال گزارش {target}",
"search.placeholder": "جستجو",
"search_popout.search_format": "راهنمای جستجوی پیشرفته",
"search_popout.tips.full_text": "جستجوی متنی ساده می‌تواند بوق‌هایی که شما نوشته‌اید، پسندیده‌اید، بازبوقیده‌اید، یا در آن‌ها از شما نام برده شده است را پیدا کند. همچنین نام‌های کاربری، نام نمایش‌یافته، و هشتگ‌ها را هم شامل می‌شود.",
"search_popout.tips.hashtag": "برچسب",
"search_popout.tips.full_text": "جست‌وجوی متنی ساده وضعیت‌هایی که که نوشته، برگزیده، تقویت‌کرده یا در آن‌ها اشاره‌شده‌اید را به اضافهٔ نام‌های کاربری، نام‌های نمایشی و برچسب‌های مطابق برمی‌گرداند.",
"search_popout.tips.hashtag": "هشتگ",
"search_popout.tips.status": "بوق",
"search_popout.tips.text": "جستجوی متنی ساده برای نام‌ها، نام‌های کاربری، و برچسب‌ها",
"search_popout.tips.user": "کاربر",
"search_results.accounts": "افراد",
"search_results.hashtags": "برچسبها",
"search_results.hashtags": "هشتگها",
"search_results.statuses": "بوق‌ها",
"search_results.statuses_fts_disabled": "جستجوی محتوای بوق‌ها در این کارساز ماستودون فعال نشده است.",
"search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
@ -404,7 +412,7 @@
"suggestions.header": "شاید این هم برایتان جالب باشد…",
"tabs_bar.federated_timeline": "همگانی",
"tabs_bar.home": "خانه",
"tabs_bar.local_timeline": "محلّی",
"tabs_bar.local_timeline": "بومی",
"tabs_bar.notifications": "اعلان‌ها",
"tabs_bar.search": "جستجو",
"time_remaining.days": "{number, plural, one {# روز} other {# روز}} باقی مانده",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# دقیقه} other {# دقیقه}} باقی مانده",
"time_remaining.moments": "زمان باقی‌مانده",
"time_remaining.seconds": "{number, plural, one {# ثانیه} other {# ثانیه}} باقی مانده",
"timeline_hint.remote_resource_not_displayed": "{resource} از دیگر کارسازها نمایش داده نمی‌شوند.",
"timeline_hint.resources.followers": "پی‌گیر",
"timeline_hint.resources.follows": "پی می‌گیرد",
"timeline_hint.resources.statuses": "بوق‌های قدیمی‌تر",
"trends.count_by_accounts": "{count} {rawCount, plural, one {نفر نوشته است} other {نفر نوشته‌اند}}",
"trends.trending_now": "پرطرفدار",
"ui.beforeunload": "اگر از ماستودون خارج شوید پیش‌نویس شما از دست خواهد رفت.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Lisää tai poista listoilta",
"account.badges.bot": "Botti",
"account.badges.group": "Ryhmä",
"account.block": "Estä @{name}",
"account.block_domain": "Piilota kaikki sisältö verkkotunnuksesta {domain}",
"account.blocked": "Estetty",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Peruuta seurauspyyntö",
"account.direct": "Viesti käyttäjälle @{name}",
"account.domain_blocked": "Verkko-osoite piilotettu",
@ -39,6 +42,10 @@
"account.unfollow": "Lakkaa seuraamasta",
"account.unmute": "Poista käyttäjän @{name} mykistys",
"account.unmute_notifications": "Poista mykistys käyttäjän @{name} ilmoituksilta",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Yritä uudestaan {retry_time, time, medium} jälkeen.",
"alert.rate_limited.title": "Määrää rajoitettu",
"alert.unexpected.message": "Tapahtui odottamaton virhe.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "vastaa",
"keyboard_shortcuts.requests": "avaa lista seurauspyynnöistä",
"keyboard_shortcuts.search": "siirry hakukenttään",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "avaa \"Aloitus\" -sarake",
"keyboard_shortcuts.toggle_hidden": "näytä/piilota sisältövaroituksella merkitty teksti",
"keyboard_shortcuts.toggle_sensitivity": "näytä/piilota media",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minuutti} other {# minuuttia}} jäljellä",
"time_remaining.moments": "Hetki jäljellä",
"time_remaining.seconds": "{number, plural, one {# sekunti} other {# sekuntia}} jäljellä",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {henkilö} other {henkilöä}} keskustelee",
"trends.trending_now": "Suosittua nyt",
"ui.beforeunload": "Luonnos häviää, jos poistut Mastodonista.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Ajouter ou retirer des listes",
"account.badges.bot": "Robot",
"account.badges.group": "Groupe",
"account.block": "Bloquer @{name}",
"account.block_domain": "Bloquer le domaine {domain}",
"account.blocked": "Bloqué·e",
"account.browse_more_on_origin_server": "Parcourir davantage sur le profil original",
"account.cancel_follow_request": "Annuler la demande de suivi",
"account.direct": "Envoyer un message direct à @{name}",
"account.domain_blocked": "Domaine bloqué",
@ -39,6 +42,10 @@
"account.unfollow": "Ne plus suivre",
"account.unmute": "Ne plus masquer @{name}",
"account.unmute_notifications": "Ne plus masquer les notifications de @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Veuillez réessayer après {retry_time, time, medium}.",
"alert.rate_limited.title": "Taux limité",
"alert.unexpected.message": "Une erreur inattendue sest produite.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Afficher les paramètres",
"column_header.unpin": "Désépingler",
"column_subheading.settings": "Paramètres",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Local seulement",
"community.column_settings.media_only": "Média uniquement",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Distant seulement",
"compose_form.direct_message_warning": "Ce pouet sera uniquement envoyé aux personnes mentionnées. Cependant, ladministration de votre instance et des instances réceptrices pourront inspecter ce message.",
"compose_form.direct_message_warning_learn_more": "En savoir plus",
"compose_form.hashtag_warning": "Ce pouet ne sera pas listé dans les recherches par hashtag car sa visibilité est réglée sur « non listé ». Seuls les pouets avec une visibilité « publique » peuvent être recherchés par hashtag.",
@ -102,7 +109,7 @@
"confirmations.block.confirm": "Bloquer",
"confirmations.block.message": "Voulez-vous vraiment bloquer {name}?",
"confirmations.delete.confirm": "Supprimer",
"confirmations.delete.message": "Voulez-vous vraiment supprimer ce pouet?",
"confirmations.delete.message": "Confirmez-vous la suppression de ce pouet?",
"confirmations.delete_list.confirm": "Supprimer",
"confirmations.delete_list.message": "Voulez-vous vraiment supprimer définitivement cette liste?",
"confirmations.domain_block.confirm": "Bloquer tout le domaine",
@ -113,7 +120,7 @@
"confirmations.mute.explanation": "Cela masquera ses messages et les messages le ou la mentionnant, mais cela lui permettra quand même de voir vos messages et de vous suivre.",
"confirmations.mute.message": "Voulez-vous vraiment masquer {name} ?",
"confirmations.redraft.confirm": "Supprimer et ré-écrire",
"confirmations.redraft.message": "Voulez-vous vraiment supprimer ce pouet pour le ré-écrire? Ses partages ainsi que ses mises en favori seront perdu·e·s et ses réponses seront orphelines.",
"confirmations.redraft.message": "Êtes-vous sûr·e de vouloir effacer ce statut pour le ré-écrire? Ses partages ainsi que ses mises en favori seront perdu·e·s et ses réponses seront orphelines.",
"confirmations.reply.confirm": "Répondre",
"confirmations.reply.message": "Répondre maintenant écrasera le message que vous rédigez actuellement. Voulez-vous vraiment continuer ?",
"confirmations.unfollow.confirm": "Ne plus suivre",
@ -212,12 +219,12 @@
"keyboard_shortcuts.back": "revenir en arrière",
"keyboard_shortcuts.blocked": "ouvrir la liste des comptes bloqués",
"keyboard_shortcuts.boost": "partager",
"keyboard_shortcuts.column": "cibler un pouet dune des colonnes",
"keyboard_shortcuts.column": "pour focaliser un statut dans lune des colonnes",
"keyboard_shortcuts.compose": "cibler la zone de rédaction",
"keyboard_shortcuts.description": "Description",
"keyboard_shortcuts.direct": "ouvrir la colonne des messages directs",
"keyboard_shortcuts.down": "descendre dans la liste",
"keyboard_shortcuts.enter": "ouvrir le pouet",
"keyboard_shortcuts.enter": "pour ouvrir le statut",
"keyboard_shortcuts.favourite": "ajouter aux favoris",
"keyboard_shortcuts.favourites": "ouvrir la liste des favoris",
"keyboard_shortcuts.federated": "ouvrir le fil public global",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "répondre",
"keyboard_shortcuts.requests": "ouvrir la liste de demandes dabonnement",
"keyboard_shortcuts.search": "cibler la zone de recherche",
"keyboard_shortcuts.spoilers": "pour afficher/masquer le champ CW",
"keyboard_shortcuts.start": "ouvrir la colonne « Pour commencer »",
"keyboard_shortcuts.toggle_hidden": "déplier/replier le texte derrière un CW",
"keyboard_shortcuts.toggle_sensitivity": "afficher/cacher les médias",
@ -322,13 +330,13 @@
"poll_button.add_poll": "Ajouter un sondage",
"poll_button.remove_poll": "Supprimer le sondage",
"privacy.change": "Ajuster la confidentialité du message",
"privacy.direct.long": "Nenvoyer quaux personnes mentionnées",
"privacy.direct.long": "Visible uniquement par les comptes mentionnés",
"privacy.direct.short": "Direct",
"privacy.private.long": "Seul·e·s vos abonné·e·s verront vos statuts",
"privacy.private.long": "Visible uniquement par vos abonné·e·s",
"privacy.private.short": "Abonné·e·s uniquement",
"privacy.public.long": "Afficher dans les fils publics",
"privacy.public.long": "Visible par tou·te·s, affiché dans les fils publics",
"privacy.public.short": "Public",
"privacy.unlisted.long": "Ne pas afficher dans les fils publics",
"privacy.unlisted.long": "Visible par tou·te·s, mais pas dans les fils publics",
"privacy.unlisted.short": "Non listé",
"refresh": "Actualiser",
"regeneration_indicator.label": "Chargement…",
@ -350,7 +358,7 @@
"search_popout.search_format": "Recherche avancée",
"search_popout.tips.full_text": "Un texte normal retourne les pouets que vous avez écris, mis en favori, partagés, ou vous mentionnant, ainsi que les identifiants, les noms affichés, et les hashtags des personnes et messages correspondant.",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "pouet",
"search_popout.tips.status": "statuts",
"search_popout.tips.text": "Un texte simple renvoie les noms affichés, les identifiants et les hashtags correspondants",
"search_popout.tips.user": "utilisateur·ice",
"search_results.accounts": "Comptes",
@ -359,7 +367,7 @@
"search_results.statuses_fts_disabled": "La recherche de pouets par leur contenu n'est pas activée sur ce serveur Mastodon.",
"search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
"status.admin_account": "Ouvrir linterface de modération pour @{name}",
"status.admin_status": "Ouvrir ce pouet dans linterface de modération",
"status.admin_status": "Ouvrir ce statut dans linterface de modération",
"status.block": "Bloquer @{name}",
"status.bookmark": "Ajouter aux marque-pages",
"status.cancel_reblog_private": "Annuler le partage",
@ -377,7 +385,7 @@
"status.more": "Plus",
"status.mute": "Masquer @{name}",
"status.mute_conversation": "Masquer la conversation",
"status.open": "Voir les détails du pouet",
"status.open": "Déplier ce statut",
"status.pin": "Épingler sur le profil",
"status.pinned": "Pouet épinglé",
"status.read_more": "En savoir plus",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} restantes",
"time_remaining.moments": "Encore quelques instants",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} restantes",
"timeline_hint.remote_resource_not_displayed": "{resource} des autres serveurs ne sont pas affichés.",
"timeline_hint.resources.followers": "Les abonnés",
"timeline_hint.resources.follows": "Les abonnements",
"timeline_hint.resources.statuses": "Les anciens pouets",
"trends.count_by_accounts": "{count} {rawCount, plural, one {personne discute} other {personnes discutent}}",
"trends.trending_now": "Tendance en ce moment",
"ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot",
"account.badges.group": "Group",
"account.block": "Block @{name}",
"account.block_domain": "Hide everything from {domain}",
"account.blocked": "Blocked",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Direct message @{name}",
"account.domain_blocked": "Domain hidden",
@ -39,6 +42,10 @@
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "An unexpected error occurred.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "to reply",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -257,7 +265,7 @@
"lists.subheading": "Your lists",
"load_pending": "{count, plural, one {# new item} other {# new items}}",
"loading_indicator.label": "Loading...",
"media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
"media_gallery.toggle_visible": "Hide media",
"missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found",
"mute_modal.hide_notifications": "Hide notifications from this user?",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Engadir ou eliminar das listaxes",
"account.badges.bot": "Bot",
"account.badges.group": "Grupo",
"account.block": "Bloquear @{name}",
"account.block_domain": "Agochar todo de {domain}",
"account.blocked": "Bloqueada",
"account.browse_more_on_origin_server": "Busca máis no perfil orixinal",
"account.cancel_follow_request": "Desbotar solicitude de seguimento",
"account.direct": "Mensaxe directa @{name}",
"account.domain_blocked": "Dominio agochado",
@ -39,6 +42,10 @@
"account.unfollow": "Deixar de seguir",
"account.unmute": "Deixar de silenciar a @{name}",
"account.unmute_notifications": "Deixar de silenciar as notificacións de @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Téntao novamente após {retry_time, time, medium}.",
"alert.rate_limited.title": "Límite de intentos",
"alert.unexpected.message": "Ocorreu un erro non agardado.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Amosar axustes",
"column_header.unpin": "Desapegar",
"column_subheading.settings": "Axustes",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Só local",
"community.column_settings.media_only": "Só multimedia",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Só remoto",
"compose_form.direct_message_warning": "Este toot só será enviado ás usuarias mencionadas.",
"compose_form.direct_message_warning_learn_more": "Coñecer máis",
"compose_form.hashtag_warning": "Este toot non aparecerá baixo ningún cancelo (hashtag) porque non está listado. Só se poden procurar toots públicos por cancelos.",
@ -155,7 +162,7 @@
"empty_column.hashtag": "Aínda non hai nada con este cancelo.",
"empty_column.home": "A túa cronoloxía inicial está baleira! Visita {public} ou emprega a procura para atopar outras usuarias.",
"empty_column.home.public_timeline": "a cronoloxía pública",
"empty_column.list": "Aínda non hai nada en esta lista. Cando as usuarias incluídas na lista publiquen mensaxes, aparecerán aquí.",
"empty_column.list": "Aínda non hai nada nesta listaxe. Cando os usuarios incluídas na listaxe publiquen mensaxes, amosaranse aquí.",
"empty_column.lists": "Aínda non tes listaxes. Cando crees unha, amosarase aquí.",
"empty_column.mutes": "Aínda non silenciaches a ningúnha usuaria.",
"empty_column.notifications": "Aínda non tes notificacións. Interactúa con outras para comezar unha conversa.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "para responder",
"keyboard_shortcuts.requests": "para abrir a listaxe das peticións de seguimento",
"keyboard_shortcuts.search": "para destacar a procura",
"keyboard_shortcuts.spoilers": "mostrar/ocultar campo CW",
"keyboard_shortcuts.start": "para abrir a columna dos \"primeiros pasos\"",
"keyboard_shortcuts.toggle_hidden": "para amosar/agochar texto detrás do aviso de contido (AC)",
"keyboard_shortcuts.toggle_sensitivity": "para amosar/agochar contido multimedia",
@ -290,7 +298,7 @@
"notification.mention": "{name} mencionoute",
"notification.own_poll": "A túa enquisa rematou",
"notification.poll": "Unha enquisa na que votaches rematou",
"notification.reblog": "{name} promoveu o teu estado",
"notification.reblog": "{name} compartiu o teu estado",
"notifications.clear": "Limpar notificacións",
"notifications.clear_confirmation": "Tes a certeza de querer limpar de xeito permanente todas as túas notificacións?",
"notifications.column_settings.alert": "Notificacións de escritorio",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minuto} other {# minutos}} restantes",
"time_remaining.moments": "Momentos restantes",
"time_remaining.seconds": "{number, plural, one {# segundo} other {# segundos}} restantes",
"timeline_hint.remote_resource_not_displayed": "Non se mostran {resource} desde outros servidores.",
"timeline_hint.resources.followers": "Seguidoras",
"timeline_hint.resources.follows": "Seguindo",
"timeline_hint.resources.statuses": "Toots antigos",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persoa} other {persoas}} falando",
"trends.trending_now": "Tendencias actuais",
"ui.beforeunload": "O borrador perderase se saes de Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "הוסף או הסר מהרשימות",
"account.badges.bot": "בוט",
"account.badges.group": "Group",
"account.block": "חסימת @{name}",
"account.block_domain": "להסתיר הכל מהקהילה {domain}",
"account.blocked": "חסום",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "בטל בקשת מעקב",
"account.direct": "Direct Message @{name}",
"account.domain_blocked": "הדומיין חסוי",
@ -39,6 +42,10 @@
"account.unfollow": "הפסקת מעקב",
"account.unmute": "הפסקת השתקת @{name}",
"account.unmute_notifications": "להפסיק הסתרת הודעות מעם @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "אירעה שגיאה בלתי צפויה.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "לענות",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "להתמקד בחלון החיפוש",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.trending_now": "Trending now",
"ui.beforeunload": "הטיוטא תאבד אם תעזבו את מסטודון.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "सूची में जोड़ें या हटाए",
"account.badges.bot": "बॉट",
"account.badges.group": "Group",
"account.block": "@{name} को ब्लॉक करें",
"account.block_domain": "{domain} के सारी चीज़े छुपाएं",
"account.blocked": "ब्लॉक",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "फ़ॉलो रिक्वेस्ट रद्द करें",
"account.direct": "प्रत्यक्ष संदेश @{name}",
"account.domain_blocked": "छिपा हुआ डोमेन",
@ -39,6 +42,10 @@
"account.unfollow": "अनफॉलो करें",
"account.unmute": "अनम्यूट @{name}",
"account.unmute_notifications": "@{name} के नोटिफिकेशन अनम्यूट करे",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "कृप्या {retry_time, time, medium} के बाद दुबारा कोशिश करें",
"alert.rate_limited.title": "सीमित दर",
"alert.unexpected.message": "एक अप्रत्याशित त्रुटि हुई है!",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "जवाब के लिए",
"keyboard_shortcuts.requests": "फॉलो रिक्वेस्ट लिस्ट खोलने के लिए",
"keyboard_shortcuts.search": "गहरी खोज के लिए",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -412,11 +420,15 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"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})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot",
"account.badges.group": "Group",
"account.block": "Blokiraj @{name}",
"account.block_domain": "Sakrij sve sa {domain}",
"account.blocked": "Blocked",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Cancel follow request",
"account.direct": "Direct Message @{name}",
"account.domain_blocked": "Domain hidden",
@ -39,6 +42,10 @@
"account.unfollow": "Prestani slijediti",
"account.unmute": "Poništi utišavanje @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "An unexpected error occurred.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "to reply",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Hozzáadás vagy eltávolítás a listáról",
"account.badges.bot": "Bot",
"account.badges.group": "Csoport",
"account.block": "@{name} letiltása",
"account.block_domain": "Minden elrejtése innen: {domain}",
"account.blocked": "Letiltva",
"account.browse_more_on_origin_server": "További böngészés az eredeti profilon",
"account.cancel_follow_request": "Követési kérelem törlése",
"account.direct": "Közvetlen üzenet @{name} számára",
"account.domain_blocked": "Rejtett domain",
@ -39,6 +42,10 @@
"account.unfollow": "Követés vége",
"account.unmute": "@{name} némítás feloldása",
"account.unmute_notifications": "@{name} némított értesítéseinek feloldása",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Próbáld újra {retry_time, time, medium} után.",
"alert.rate_limited.title": "Forgalomkorlátozás",
"alert.unexpected.message": "Váratlan hiba történt.",
@ -74,9 +81,9 @@
"column_header.show_settings": "Beállítások mutatása",
"column_header.unpin": "Kitűzés eltávolítása",
"column_subheading.settings": "Beállítások",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Csak helyi",
"community.column_settings.media_only": "Csak média",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Csak távoli",
"compose_form.direct_message_warning": "Ezt a tülköt csak a benne megemlített felhasználók láthatják majd.",
"compose_form.direct_message_warning_learn_more": "Tudj meg többet",
"compose_form.hashtag_warning": "Ez a tülköd nem fog megjelenni semmilyen hashtag alatt mivel listázatlan. Csak nyilvános tülkök kereshetőek hashtaggel.",
@ -236,6 +243,7 @@
"keyboard_shortcuts.reply": "válasz",
"keyboard_shortcuts.requests": "követési kérések listájának megnyitása",
"keyboard_shortcuts.search": "fókuszálás a keresőre",
"keyboard_shortcuts.spoilers": "CW mező mutatása/elrejtése",
"keyboard_shortcuts.start": "\"Első lépések\" megnyitása",
"keyboard_shortcuts.toggle_hidden": "tartalmi figyelmeztetéssel ellátott szöveg mutatása/elrejtése",
"keyboard_shortcuts.toggle_sensitivity": "média mutatása/elrejtése",
@ -412,6 +420,10 @@
"time_remaining.minutes": "{number, plural, one {# perc} other {# perc}} van hátra",
"time_remaining.moments": "Pillanatok vannak hátra",
"time_remaining.seconds": "{number, plural, one {# másodperc} other {# másodperc}} van hátra",
"timeline_hint.remote_resource_not_displayed": "más szerverekről származó {resource} tartalmakat nem mutatjuk.",
"timeline_hint.resources.followers": "Követő",
"timeline_hint.resources.follows": "Követett",
"timeline_hint.resources.statuses": "Régi tülkök",
"trends.count_by_accounts": "{count} {rawCount, plural, one {résztvevő} other {résztvevő}} beszélget",
"trends.trending_now": "Most felkapott",
"ui.beforeunload": "A piszkozatod el fog veszni, ha elhagyod a Mastodont.",

View File

@ -1,10 +1,13 @@
{
"account.account_note_header": "Your note for @{name}",
"account.add_account_note": "Add note for @{name}",
"account.add_or_remove_from_list": "Աւելացնել կամ հեռացնել ցանկերից",
"account.badges.bot": "Բոտ",
"account.badges.group": "Խումբ",
"account.block": "Արգելափակել @{name}֊ին",
"account.block_domain": "Թաքցնել ամէնը հետեւեալ տիրոյթից՝ {domain}",
"account.blocked": "Արգելափակուած է",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "չեղարկել հետեւելու հայցը",
"account.direct": "Նամակ գրել @{name} -ին",
"account.domain_blocked": "Տիրոյթը արգելափակուած է",
@ -17,7 +20,7 @@
"account.follows.empty": "Այս օգտատէրը դեռ ոչ մէկի չի հետեւում։",
"account.follows_you": "Հետեւում է քեզ",
"account.hide_reblogs": "Թաքցնել @{name}֊ի տարածածները",
"account.last_status": "Վերջին անգամ ակտիւ էր",
"account.last_status": "Վերջին թութը",
"account.link_verified_on": "Սոյն յղման տիրապետումը ստուգուած է՝ {date}֊ին",
"account.locked_info": "Սոյն հաշուի գաղտնիութեան մակարդակը նշուած է որպէս՝ փակ։ Հաշուի տէրն ընտրում է, թէ ով կարող է հետեւել իրեն։",
"account.media": "Մեդիա",
@ -39,6 +42,10 @@
"account.unfollow": "Ապահետեւել",
"account.unmute": "Ապալռեցնել @{name}֊ին",
"account.unmute_notifications": "Միացնել ծանուցումները @{name}֊ից",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.placeholder": "No comment provided",
"account_note.save": "Save",
"alert.rate_limited.message": "Փորձէք որոշ ժամանակ անց՝ {retry_time, time, medium}։",
"alert.rate_limited.title": "Գործողութիւնների յաճախութիւնը գերազանցում է թոյլատրելին",
"alert.unexpected.message": "Անսպասելի սխալ տեղի ունեցաւ։",
@ -58,7 +65,7 @@
"column.direct": "Հասցէագրուած հաղորդագրութիւններ",
"column.directory": "Զննել անձնական էջերը",
"column.domain_blocks": "Թաքցուած տիրոյթները",
"column.favourites": "Հավանածներ",
"column.favourites": "Հաւանածներ",
"column.follow_requests": "Հետեւելու հայցեր",
"column.home": "Հիմնական",
"column.lists": "Ցանկեր",
@ -68,27 +75,27 @@
"column.public": "Դաշնային հոսք",
"column_back_button.label": "Ետ",
"column_header.hide_settings": "Թաքցնել կարգավորումները",
"column_header.moveLeft_settings": "Տեղաշարժել սյունը ձախ",
"column_header.moveRight_settings": "Տեղաշարժել սյունը աջ",
"column_header.moveLeft_settings": "Տեղաշարժել սիւնը ձախ",
"column_header.moveRight_settings": "Տեղաշարժել սիւնը աջ",
"column_header.pin": "Ամրացնել",
"column_header.show_settings": "Ցուցադրել կարգավորումները",
"column_header.unpin": "Հանել",
"column_subheading.settings": "Կարգավորումներ",
"community.column_settings.local_only": "Local only",
"community.column_settings.local_only": "Միայն ներքին",
"community.column_settings.media_only": "Media only",
"community.column_settings.remote_only": "Remote only",
"community.column_settings.remote_only": "Միայն հեռակա",
"compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
"compose_form.direct_message_warning_learn_more": "Իմանալ ավելին",
"compose_form.hashtag_warning": "Այս թութը չի հաշվառվի որեւէ պիտակի տակ, քանզի այն ծածուկ է։ Միայն հրապարակային թթերը հնարավոր է որոնել պիտակներով։",
"compose_form.lock_disclaimer": "Քո հաշիվը {locked} չէ։ Յուրաքանչյուր ոք կարող է հետեւել քեզ եւ տեսնել միայն հետեւողների համար նախատեսված գրառումները։",
"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.poll.switch_to_multiple": "Հարցումը դարձնել բազմակի ընտրությամբ",
"compose_form.poll.switch_to_single": "Հարցումը դարձնել եզակի ընտրությամբ",
"compose_form.poll.switch_to_multiple": "Հարցումը դարձնել բազմակի ընտրութեամբ",
"compose_form.poll.switch_to_single": "Հարցումը դարձնել եզակի ընտրութեամբ",
"compose_form.publish": "Թթել",
"compose_form.publish_loud": "Թթե՜լ",
"compose_form.sensitive.hide": "Նշել մեդիան որպէս դիւրազգաց",
@ -124,24 +131,24 @@
"conversation.with": "{names}֊երի հետ",
"directory.federated": "Յայտնի դաշնեզերքից",
"directory.local": "{domain} տիրոյթից միայն",
"directory.new_arrivals": "Նորութիւններ",
"directory.new_arrivals": "Նորեկներ",
"directory.recently_active": "Վերջերս ակտիւ",
"embed.instructions": "Այս թութը քո կայքում ներդնելու համար կարող ես պատճենել ներքոհիշյալ կոդը։",
"embed.instructions": "Այս թութը քո կայքում ներդնելու համար կարող ես պատճէնել ներքինանալ կոդը։",
"embed.preview": "Ահա, թե ինչ տեսք կունենա այն՝",
"emoji_button.activity": "Զբաղմունքներ",
"emoji_button.custom": "Հատուկ",
"emoji_button.flags": "Դրոշներ",
"emoji_button.food": "Կերուխում",
"emoji_button.label": "Էմոջի ավելացնել",
"emoji_button.nature": "Բնություն",
"emoji_button.nature": "Բնութիւն",
"emoji_button.not_found": "Նման էմոջիներ դեռ չեն հայտնաբերվել։ (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "Առարկաներ",
"emoji_button.people": "Մարդիկ",
"emoji_button.recent": "Հաճախ օգտագործվող",
"emoji_button.search": "Որոնել…",
"emoji_button.search_results": "Որոնման արդյունքներ",
"emoji_button.search_results": "Որոնման արդիւնքներ",
"emoji_button.symbols": "Նշաններ",
"emoji_button.travel": "Ուղեւորություն եւ տեղանքներ",
"emoji_button.travel": "Ուղեւորութիւն եւ տեղանքներ",
"empty_column.account_timeline": "Այստեղ թթեր չկա՛ն։",
"empty_column.account_unavailable": "Անձնական էջը հասանելի չի",
"empty_column.blocks": "Դու դեռ ոչ մէկի չես արգելափակել։",
@ -151,31 +158,31 @@
"empty_column.domain_blocks": "Թաքցուած տիրոյթներ դեռ չկան։",
"empty_column.favourited_statuses": "Դու դեռ չունես որեւէ հաւանած թութ։ Երբ հաւանես, դրանք կերեւան այստեղ։",
"empty_column.favourites": "Այս թութը ոչ մէկ դեռ չի հաւանել։ Հաւանողները կերեւան այստեղ, երբ նշեն թութը հաւանած։",
"empty_column.follow_requests": "Դու դեռ չունես որեւէ հետևելու հայտ։ Բոլոր նման հայտերը կհայտնվեն այստեղ։",
"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.notifications": "Ոչ մի ծանուցում դեռ չունես։ Բզիր մյուսներին՝ խոսակցությունը սկսելու համար։",
"empty_column.public": "Այստեղ բան չկա՛։ Հրապարակային մի բան գրիր կամ հետեւիր այլ հանգույցներից էակների՝ այն լցնելու համար։",
"error.unexpected_crash.explanation": "Մեր ծրագրակազմում վրիպակի կամ դիտարկչի անհամատեղելիության պատճառով այս էջը չի կարող լիարժեք պատկերվել։",
"empty_column.notifications": "Ոչ մի ծանուցում դեռ չունես։ Բզիր միւսներին՝ խօսակցութիւնը սկսելու համար։",
"empty_column.public": "Այստեղ բան չկա՛յ։ Հրապարակային մի բան գրիր կամ հետեւիր այլ հանգոյցներից էակների՝ այն լցնելու համար։",
"error.unexpected_crash.explanation": "Մեր ծրագրակազմում վրիպակի կամ դիտարկչի անհամատեղելիութեան պատճառով այս էջը չի կարող լիարժէք պատկերուել։",
"error.unexpected_crash.next_steps": "Փորձիր թարմացնել էջը։ Եթե դա չօգնի ապա կարող ես օգտվել Մաստադոնից ուրիշ դիտարկիչով կամ հավելվածով։",
"errors.unexpected_crash.copy_stacktrace": "Պատճենել սթաքթրեյսը սեղմատախտակին",
"errors.unexpected_crash.report_issue": "Զեկուցել խնդրի մասին",
"follow_request.authorize": "Վավերացնել",
"follow_request.reject": "Մերժել",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
"follow_requests.unlocked_explanation": "Այս հարցումը ուղարկված է հաշվից, որի համար {domain}-ի անձնակազմը միացրել է ձեռքով ստուգում։",
"getting_started.developers": "Մշակողներ",
"getting_started.directory": "Օգտատերերի շտեմարան",
"getting_started.directory": "Օգտատէրերի շտեմարան",
"getting_started.documentation": "Փաստաթղթեր",
"getting_started.heading": "Ինչպես սկսել",
"getting_started.invite": "Հրավիրել մարդկանց",
"getting_started.invite": "Հրաւիրել մարդկանց",
"getting_started.open_source_notice": "Մաստոդոնը բաց ելատեքստով ծրագրակազմ է։ Կարող ես ներդրում անել կամ վրեպներ զեկուցել ԳիթՀաբում՝ {github}։",
"getting_started.security": "Հաշվի կարգավորումներ",
"getting_started.terms": "Ծառայության պայմանները",
"hashtag.column_header.tag_mode.all": "և {additional}",
"getting_started.security": "Հաշուի կարգաւորումներ",
"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_settings.select.no_options_message": "Առաջարկներ չկան",
@ -187,34 +194,34 @@
"home.column_settings.basic": "Հիմնական",
"home.column_settings.show_reblogs": "Ցուցադրել տարածածները",
"home.column_settings.show_replies": "Ցուցադրել պատասխանները",
"home.hide_announcements": "Թաքցնել հայտարարությունները",
"home.show_announcements": "Ցուցադրել հայտարարությունները",
"home.hide_announcements": "Թաքցնել յայտարարութիւնները",
"home.show_announcements": "Ցուցադրել յայտարարութիւնները",
"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.text": "Դաշնեզերքի հարեւան հանգոյցների հանրային գրառումները կը յայտնուեն դաշնային հոսքում։",
"introduction.federation.home.headline": "Հիմնական",
"introduction.federation.home.text": "Այն անձանց թթերը ում հետևում ես, կհայտնվի հիմնական հոսքում։ Դու կարող ես հետևել ցանկացած անձի ցանկացած հանգույցից։",
"introduction.federation.home.text": "Այն անձանց թթերը ում հետևում ես, կը յայտնուեն հիմնական հոսքում։ Դու կարող ես հետեւել ցանկացած անձի ցանկացած հանգոյցից։",
"introduction.federation.local.headline": "Տեղային",
"introduction.federation.local.text": "Տեղական հոսքում կարող ես տեսնել քո հանգույցի բոլոր հանրային գրառումները։",
"introduction.federation.local.text": "Տեղական հոսքում կարող ես տեսնել քո հանգոյցի բոլոր հանրային գրառումները։",
"introduction.interactions.action": "Finish toot-orial!",
"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.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.boost": "տարածելու համար",
"keyboard_shortcuts.column": "սյուներից մեկի վրա սեւեռվելու համար",
"keyboard_shortcuts.column": "սիւներից մէկի վրայ սեւեռուելու համար",
"keyboard_shortcuts.compose": "շարադրման տիրույթին սեւեռվելու համար",
"keyboard_shortcuts.description": "Նկարագրություն",
"keyboard_shortcuts.description": "Նկարագրութիւն",
"keyboard_shortcuts.direct": "հասցեագրված գրվածքների հոսքը բացելու համար",
"keyboard_shortcuts.down": "ցանկով ներքեւ շարժվելու համար",
"keyboard_shortcuts.enter": "թութը բացելու համար",
@ -229,13 +236,14 @@
"keyboard_shortcuts.mention": "հեղինակին նշելու համար",
"keyboard_shortcuts.muted": "լռեցված օգտատերերի ցանկը բացելու համար",
"keyboard_shortcuts.my_profile": "սեփական էջին անցնելու համար",
"keyboard_shortcuts.notifications": "ծանուցումեների սյունակը բացելու համար",
"keyboard_shortcuts.notifications": "ծանուցումների սիւնակը բացելու համար",
"keyboard_shortcuts.open_media": "ցուցադրել մեդիան",
"keyboard_shortcuts.pinned": "ամրացուած թթերի ցանկը բացելու համար",
"keyboard_shortcuts.profile": "հեղինակի անձնական էջը բացելու համար",
"keyboard_shortcuts.reply": "պատասխանելու համար",
"keyboard_shortcuts.requests": "հետեւելու հայցերի ցանկը դիտելու համար",
"keyboard_shortcuts.search": "որոնման դաշտին սեւեռվելու համար",
"keyboard_shortcuts.spoilers": "to show/hide CW field",
"keyboard_shortcuts.start": "«սկսել» սիւնակը բացելու համար",
"keyboard_shortcuts.toggle_hidden": "CW֊ի ետեւի տեքստը ցուցադրել֊թաքցնելու համար",
"keyboard_shortcuts.toggle_sensitivity": "մեդիան ցուցադրել֊թաքցնելու համար",
@ -255,7 +263,7 @@
"lists.new.title_placeholder": "Նոր ցանկի վերնագիր",
"lists.search": "Փնտրել քո հետեւած մարդկանց մեջ",
"lists.subheading": "Քո ցանկերը",
"load_pending": "{count, plural, one {# նոր նյութ} other {# նոր նյութ}}",
"load_pending": "{count, plural, one {# նոր նիւթ} other {# նոր նիւթ}}",
"loading_indicator.label": "Բեռնվում է…",
"media_gallery.toggle_visible": "Ցուցադրել/թաքցնել",
"missing_indicator.label": "Չգտնվեց",
@ -270,23 +278,23 @@
"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": "Հետեւածներ եւ հետեւողներ",
"navigation_bar.info": "Այս հանգույցի մասին",
"navigation_bar.info": "Այս հանգոյցի մասին",
"navigation_bar.keyboard_shortcuts": "Ստեղնաշարի կարճատներ",
"navigation_bar.lists": "Ցանկեր",
"navigation_bar.logout": "Դուրս գալ",
"navigation_bar.mutes": "Լռեցրած օգտատերեր",
"navigation_bar.personal": "Անձնական",
"navigation_bar.pins": "Ամրացված թթեր",
"navigation_bar.preferences": "Նախապատվություններ",
"navigation_bar.preferences": "Նախապատուութիւններ",
"navigation_bar.public_timeline": "Դաշնային հոսք",
"navigation_bar.security": "Անվտանգություն",
"navigation_bar.security": "Անվտանգութիւն",
"notification.favourite": "{name} հավանեց թութդ",
"notification.follow": "{name} սկսեց հետեւել քեզ",
"notification.follow_request": "{name} քեզ հետևելու հայց է ուղարկել",
"notification.follow_request": "{name} քեզ հետեւելու հայց է ուղարկել",
"notification.mention": "{name} նշեց քեզ",
"notification.own_poll": "Հարցումդ աւարտուեց",
"notification.poll": "Հարցումը, ուր դու քուէարկել ես, աւարտուեց։",
@ -294,7 +302,7 @@
"notifications.clear": "Մաքրել ծանուցումները",
"notifications.clear_confirmation": "Վստա՞հ ես, որ ուզում ես մշտապես մաքրել քո բոլոր ծանուցումները։",
"notifications.column_settings.alert": "Աշխատատիրույթի ծանուցումներ",
"notifications.column_settings.favourite": "Հավանածներից՝",
"notifications.column_settings.favourite": "Հաւանածներից՝",
"notifications.column_settings.filter_bar.advanced": "Ցուցադրել բոլոր կատեգորիաները",
"notifications.column_settings.filter_bar.category": "Արագ զտման վահանակ",
"notifications.column_settings.filter_bar.show": "Ցուցադրել",
@ -304,7 +312,7 @@
"notifications.column_settings.poll": "Հարցման արդիւնքները՝",
"notifications.column_settings.push": "Հրելու ծանուցումներ",
"notifications.column_settings.reblog": "Տարածածներից՝",
"notifications.column_settings.show": "Ցուցադրել սյունում",
"notifications.column_settings.show": "Ցուցադրել սիւնում",
"notifications.column_settings.sound": "Ձայն հանել",
"notifications.filter.all": "Բոլորը",
"notifications.filter.boosts": "Տարածածները",
@ -321,7 +329,7 @@
"poll.voted": "Դու քուէարկել ես այս տարբերակի համար",
"poll_button.add_poll": "Աւելացնել հարցում",
"poll_button.remove_poll": "Հեռացնել հարցումը",
"privacy.change": "Կարգավորել թթի գաղտնիությունը",
"privacy.change": "Կարգաւորել թթի գաղտնիութիւնը",
"privacy.direct.long": "Թթել միայն նշված օգտատերերի համար",
"privacy.direct.short": "Հասցեագրված",
"privacy.private.long": "Թթել միայն հետեւողների համար",
@ -343,7 +351,7 @@
"report.forward": "Փոխանցել {target}֊ին",
"report.forward_hint": "Այս հաշիւ այլ հանգոյցից է։ Ուղարկե՞մ այնտեղ էլ այս բողոքի անոնիմ պատճէնը։",
"report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"report.placeholder": "Լրացուցիչ մեկնաբանություններ",
"report.placeholder": "Լրացուցիչ մեկնաբանութիւններ",
"report.submit": "Ուղարկել",
"report.target": "Բողոքել {target}֊ի մասին",
"search.placeholder": "Փնտրել",
@ -357,7 +365,7 @@
"search_results.hashtags": "Պիտակներ",
"search_results.statuses": "Թթեր",
"search_results.statuses_fts_disabled": "Այս հանգոյցում միացուած չէ ըստ բովանդակութեան թթեր փնտրելու հնարաւորութիւնը։",
"search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}",
"search_results.total": "{count, number} {count, plural, one {արդիւնք} other {արդիւնք}}",
"status.admin_account": "Բացել @{name} օգտատիրոջ մոդերացիայի դիմերէսը։",
"status.admin_status": "Բացել այս գրառումը մոդերատորի դիմերէսի մէջ",
"status.block": "Արգելափակել @{name}֊ին",
@ -372,11 +380,11 @@
"status.favourite": "Հավանել",
"status.filtered": "Զտված",
"status.load_more": "Բեռնել ավելին",
"status.media_hidden": "մեդիաբովանդակությունը թաքցված է",
"status.media_hidden": "մեդիաբովանդակութիւնը թաքցուած է",
"status.mention": "Նշել @{name}֊ին",
"status.more": "Ավելին",
"status.mute": "Լռեցնել @{name}֊ին",
"status.mute_conversation": "Լռեցնել խոսակցությունը",
"status.mute_conversation": "Լռեցնել խօսակցութիւնը",
"status.open": "Ընդարձակել այս թութը",
"status.pin": "Ամրացնել անձնական էջում",
"status.pinned": "Ամրացված թութ",
@ -384,13 +392,13 @@
"status.reblog": "Տարածել",
"status.reblog_private": "Տարածել սեփական լսարանին",
"status.reblogged_by": "{name} տարածել է",
"status.reblogs.empty": "Այս թութը ոչ մէկ դեռ չի տարածել։ Տարածողները կերեւան այստեղ, երբ որևէ մեկը տարածի։",
"status.reblogs.empty": "Այս թութը ոչ մէկ դեռ չի տարածել։ Տարածողները կերեւան այստեղ, երբ որեւէ մէկը տարածի։",
"status.redraft": "Ջնջել եւ վերակազմել",
"status.remove_bookmark": "Հեռացնել էջանիշերից",
"status.reply": "Պատասխանել",
"status.replyAll": "Պատասխանել թելին",
"status.report": "Բողոքել @{name}֊ից",
"status.sensitive_warning": "Կասկածելի բովանդակություն",
"status.sensitive_warning": "Կասկածելի բովանդակութիւն",
"status.share": "Կիսվել",
"status.show_less": "Պակաս",
"status.show_less_all": "Թաքցնել բոլոր նախազգուշացնումները",
@ -398,7 +406,7 @@
"status.show_more_all": "Ցուցադրել բոլոր նախազգուշացնումները",
"status.show_thread": "Բացել շղթան",
"status.uncached_media_warning": "Անհասանելի",
"status.unmute_conversation": "Ապալռեցնել խոսակցությունը",
"status.unmute_conversation": "Ապալռեցնել խօսակցութիւնը",
"status.unpin": "Հանել անձնական էջից",
"suggestions.dismiss": "Անտեսել առաջարկը",
"suggestions.header": "Միգուցե քեզ հետաքրքրի…",
@ -410,34 +418,38 @@
"time_remaining.days": "{number, plural, one {մնաց # օր} other {մնաց # օր}}",
"time_remaining.hours": "{number, plural, one {# ժամ} other {# ժամ}} անց",
"time_remaining.minutes": "{number, plural, one {# րոպե} other {# րոպե}} անց",
"time_remaining.moments": "Մնացել է մի քանի վարկյան",
"time_remaining.seconds": "{number, plural, one {# վայրկյան} other {# վայրկյան}} անց",
"time_remaining.moments": "Մնացել է մի քանի վարկեան",
"time_remaining.seconds": "{number, plural, one {# վարկեան} other {# վարկեան}} անց",
"timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
"timeline_hint.resources.followers": "Followers",
"timeline_hint.resources.follows": "Follows",
"timeline_hint.resources.statuses": "Older toots",
"trends.count_by_accounts": "{count} {rawCount, plural, one {հոգի} other {հոգի}} խոսում է սրա մասին",
"trends.trending_now": "Այժմ արդիական",
"ui.beforeunload": "Քո սեւագիրը կկորի, եթե լքես Մաստոդոնը։",
"upload_area.title": "Քաշիր ու նետիր՝ վերբեռնելու համար",
"upload_button.label": "Ավելացնել մեդիա",
"upload_error.limit": "Ֆայլի վերբեռնման սահմանաչափը գերազանցված է։",
"upload_error.poll": "Հարցումների հետ ֆայլ կցել հնարավոր չէ։",
"upload_form.audio_description": "Նկարագրիր ձայնագրության բովանդակությունը լսողական խնդիրներով անձանց համար",
"upload_form.description": "Նկարագրություն ավելացրու տեսողական խնդիրներ ունեցողների համար",
"upload_error.poll": "Հարցումների հետ նիշք կցել հնարաւոր չէ։",
"upload_form.audio_description": "Նկարագրիր ձայնագրութեան բովանդակութիւնը լսողական խնդիրներով անձանց համար",
"upload_form.description": "Նկարագիր՝ տեսողական խնդիրներ ունեցողների համար",
"upload_form.edit": "Խմբագրել",
"upload_form.undo": "Հետարկել",
"upload_form.video_description": "Նկարագրիր տեսանյութը լսողական կամ տեսողական խնդիրներով անձանց համար",
"upload_form.video_description": "Նկարագրիր տեսանիւթը լսողական կամ տեսողական խնդիրներով անձանց համար",
"upload_modal.analyzing_picture": "Լուսանկարի վերլուծում…",
"upload_modal.apply": "Կիրառել",
"upload_modal.description_placeholder": "Ճկուն շագանակագույն աղվեսը ցատկում է ծույլ շան վրայով",
"upload_modal.description_placeholder": "Բել դղյակի ձախ ժամն օֆ ազգությանը ցպահանջ չճշտած վնաս էր եւ փառք։",
"upload_modal.detect_text": "Հայտնբերել տեքստը նկարից",
"upload_modal.edit_media": "Խմբագրել մեդիան",
"upload_modal.hint": "Սեղմեք և տեղաշարժեք նախատեսքի վրայի շրջանակը ընտրելու այն կետը որը միշտ տեսանելի կլինի մանրապատկերներում։",
"upload_modal.hint": "Սեղմէք եւ տեղաշարժէք նախադիտման շրջանակը՝ որ ընտրէք մանրապատկերում միշտ տեսանելի կէտը։",
"upload_modal.preview_label": "Նախադիտում ({ratio})",
"upload_progress.label": "Վերբեռնվում է…",
"video.close": "Փակել տեսագրությունը",
"video.close": "Փակել տեսագրութիւնը",
"video.download": "Ներբեռնել ֆայլը",
"video.exit_fullscreen": "Անջատել լիաէկրան դիտումը",
"video.expand": "Ընդարձակել տեսագրությունը",
"video.expand": "Ընդարձակել տեսագրութիւնը",
"video.fullscreen": "Լիաէկրան",
"video.hide": "Թաքցնել տեսագրությունը",
"video.hide": "Թաքցնել տեսագրութիւնը",
"video.mute": "Լռեցնել ձայնը",
"video.pause": "Դադար տալ",
"video.play": "Նվագել",

Some files were not shown because too many files have changed in this diff Show More