Merge tag 'v2.8.2' into instance_only_statuses

This commit is contained in:
Renato "Lond" Cerqueira 2019-05-07 21:42:11 +02:00
commit 84c8b1e200
101 changed files with 1602 additions and 490 deletions

View File

@ -3,6 +3,57 @@ Changelog
All notable changes to this project will be documented in this file.
## [2.8.2] - 2019-05-05
### Added
- Add `SOURCE_TAG` environment variable ([ushitora-anqou](https://github.com/tootsuite/mastodon/pull/10698))
### Fixed
- Fix cropped hero image on frontpage ([BaptisteGelez](https://github.com/tootsuite/mastodon/pull/10702))
- Fix blurhash gem not compiling on some operating systems ([Gargron](https://github.com/tootsuite/mastodon/pull/10700))
- Fix unexpected CSS animations in some browsers ([ThibG](https://github.com/tootsuite/mastodon/pull/10699))
- Fix closing video modal scrolling timelines to top ([ThibG](https://github.com/tootsuite/mastodon/pull/10695))
## [2.8.1] - 2019-05-04
### Added
- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663))
- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676))
- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603))
- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600))
- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666))
- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
### Changed
- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683))
- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655))
- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682))
- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674))
### Fixed
- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621))
- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684))
- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614))
- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593))
- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657))
- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622))
- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632))
- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633))
- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624))
- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623))
- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553))
- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605))
- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565))
- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549))
- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604))
- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594))
- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586))
## [2.8.0] - 2019-04-10
### Added

17
Gemfile
View File

@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'streamio-ffmpeg', '~> 3.0'
gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.6'
@ -29,7 +30,7 @@ gem 'browser'
gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639'
gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.3'
gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.6'
gem 'devise-two-factor', '~> 3.0'
@ -42,7 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'
gem 'doorkeeper', '~> 5.0'
gem 'doorkeeper', '~> 5.1'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
@ -65,7 +66,7 @@ gem 'ox', '~> 2.10'
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
gem 'pundit', '~> 2.0'
gem 'premailer-rails'
gem 'rack-attack', '~> 5.4'
gem 'rack-attack', '~> 6.0'
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6'
@ -107,7 +108,7 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.16'
gem 'capybara', '~> 3.18'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.1'
@ -123,14 +124,14 @@ group :development do
gem 'annotate', '~> 2.7'
gem 'better_errors', '~> 2.5'
gem 'binding_of_caller', '~> 0.7'
gem 'bullet', '~> 5.9'
gem 'bullet', '~> 6.0'
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', '~> 0.67', require: false
gem 'rubocop', '~> 0.68', require: false
gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.57', require: false
gem 'scss_lint', '~> 0.58', require: false
gem 'capistrano', '~> 3.11'
gem 'capistrano-rails', '~> 1.4'
@ -142,7 +143,7 @@ group :development do
end
group :production do
gem 'lograge', '~> 0.10'
gem 'lograge', '~> 0.11'
gem 'redis-rails', '~> 5.0'
end

View File

@ -66,8 +66,8 @@ GEM
public_suffix (>= 2.0.2, < 4.0)
airbrussh (1.3.0)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.4)
activerecord (>= 3.2, < 6.0)
annotate (2.7.5)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 13.0)
arel (9.0.0)
ast (2.4.0)
@ -76,16 +76,16 @@ GEM
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.2)
aws-partitions (1.147.0)
aws-sdk-core (3.48.3)
aws-partitions (1.151.0)
aws-sdk-core (3.48.4)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.16.0)
aws-sdk-kms (1.17.0)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.36.0)
aws-sdk-s3 (1.36.1)
aws-sdk-core (~> 3, >= 3.48.2)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
@ -99,12 +99,14 @@ GEM
rack (>= 0.9.0)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
bootsnap (1.4.3)
blurhash (0.1.3)
ffi (~> 1.10.0)
bootsnap (1.4.4)
msgpack (~> 1.0)
brakeman (4.5.0)
browser (2.5.3)
builder (3.2.3)
bullet (5.9.0)
bullet (6.0.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.6.1)
@ -127,7 +129,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.16.1)
capybara (3.18.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -143,8 +145,8 @@ GEM
elasticsearch (>= 2.0.0)
elasticsearch-dsl
chunky_png (1.3.10)
cld3 (3.2.3)
ffi (>= 1.1.0, < 1.10.0)
cld3 (3.2.4)
ffi (>= 1.1.0, < 1.11.0)
climate_control (0.2.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
@ -184,8 +186,8 @@ GEM
docile (1.3.0)
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.0.2)
railties (>= 4.2)
doorkeeper (5.1.0)
railties (>= 5)
dotenv (2.7.2)
dotenv-rails (2.7.2)
dotenv (= 2.7.2)
@ -205,14 +207,14 @@ GEM
et-orbi (1.1.6)
tzinfo
excon (0.62.0)
fabrication (2.20.1)
fabrication (2.20.2)
faker (1.9.3)
i18n (>= 0.7)
faraday (0.15.0)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
fastimage (2.1.5)
ffi (1.9.25)
ffi (1.10.0)
fog-core (2.1.0)
builder
excon (~> 0.58)
@ -318,7 +320,7 @@ GEM
letter_opener (~> 1.0)
railties (>= 3.2)
link_header (0.0.8)
lograge (0.10.0)
lograge (0.11.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
@ -346,7 +348,7 @@ GEM
mini_mime (1.0.1)
mini_portile2 (2.4.0)
minitest (5.11.3)
msgpack (1.2.9)
msgpack (1.2.10)
multi_json (1.13.1)
multipart-post (2.0.0)
necromancer (0.4.0)
@ -355,7 +357,7 @@ GEM
net-ssh (>= 2.6.5)
net-ssh (5.0.2)
nio4r (2.3.1)
nokogiri (1.10.2)
nokogiri (1.10.3)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.0)
nokogiri (~> 1.8, >= 1.8.4)
@ -364,7 +366,7 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.7.11)
oj (3.7.12)
omniauth (1.9.0)
hashie (>= 3.4.6, < 3.7.0)
rack (>= 1.6.2, < 3)
@ -393,7 +395,7 @@ GEM
parallel (1.17.0)
parallel_tests (2.28.0)
parallel
parser (2.6.2.0)
parser (2.6.3.0)
ast (~> 2.4.0)
pastel (0.7.2)
equatable (~> 0.5.0)
@ -418,14 +420,13 @@ GEM
pry (~> 0.10)
pry-rails (0.3.9)
pry (>= 0.10.4)
psych (3.1.0)
public_suffix (3.0.3)
puma (3.12.1)
pundit (2.0.1)
activesupport (>= 3.0.0)
raabro (1.1.6)
rack (2.0.7)
rack-attack (5.4.2)
rack-attack (6.0.0)
rack (>= 1.0, < 3)
rack-cors (1.0.3)
rack-protection (2.0.5)
@ -470,8 +471,8 @@ GEM
rainbow (3.0.0)
rake (12.3.2)
rb-fsevent (0.10.3)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
rb-inotify (0.10.0)
ffi (~> 1.0)
rdf (3.0.9)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
@ -496,7 +497,7 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.5.0)
redis (>= 2.2, < 5)
regexp_parser (1.3.0)
regexp_parser (1.4.0)
request_store (1.4.1)
rack (>= 1.4)
responders (2.4.1)
@ -526,11 +527,10 @@ GEM
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.8.0)
rubocop (0.67.1)
rubocop (0.68.1)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
psych (>= 3.1.0)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.6)
@ -544,15 +544,15 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
sass (3.6.0)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
scss_lint (0.57.1)
scss_lint (0.58.0)
rake (>= 0.9, < 13)
sass (~> 3.5, >= 3.5.5)
sidekiq (5.2.5)
sidekiq (5.2.7)
connection_pool (~> 2.2, >= 2.2.2)
rack (>= 1.5.0)
rack-protection (>= 1.5.0)
@ -564,7 +564,7 @@ GEM
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.12)
sidekiq-unique-jobs (6.0.13)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 7.0)
thor (~> 0)
@ -640,7 +640,7 @@ GEM
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
webpush (0.3.7)
webpush (0.3.8)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.0)
@ -661,26 +661,27 @@ DEPENDENCIES
aws-sdk-s3 (~> 1.36)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
bootsnap (~> 1.4)
brakeman (~> 4.5)
browser
bullet (~> 5.9)
bullet (~> 6.0)
bundler-audit (~> 0.6)
capistrano (~> 3.11)
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.16)
capybara (~> 3.18)
charlock_holmes (~> 0.7.6)
chewy (~> 5.0)
cld3 (~> 3.2.3)
cld3 (~> 3.2.4)
climate_control (~> 0.2)
concurrent-ruby
derailed_benchmarks
devise (~> 4.6)
devise-two-factor (~> 3.0)
devise_pam_authenticatable2 (~> 9.2)
doorkeeper (~> 5.0)
doorkeeper (~> 5.1)
dotenv-rails (~> 2.7)
fabrication (~> 2.20)
faker (~> 1.9)
@ -706,7 +707,7 @@ DEPENDENCIES
letter_opener (~> 1.7)
letter_opener_web (~> 1.3)
link_header (~> 0.0)
lograge (~> 0.10)
lograge (~> 0.11)
makara (~> 0.4)
mario-redis-lock (~> 1.2)
memory_profiler
@ -734,7 +735,7 @@ DEPENDENCIES
pry-rails (~> 0.3)
puma (~> 3.12)
pundit (~> 2.0)
rack-attack (~> 5.4)
rack-attack (~> 6.0)
rack-cors (~> 1.0)
rails (~> 5.2.3)
rails-controller-testing (~> 1.0)
@ -747,9 +748,9 @@ DEPENDENCIES
rqrcode (~> 0.10)
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.67)
rubocop (~> 0.68)
sanitize (~> 5.0)
scss_lint (~> 0.57)
scss_lint (~> 0.58)
sidekiq (~> 5.2)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)

View File

@ -13,13 +13,25 @@ module Admin
authorize :domain_block, :create?
@domain_block = DomainBlock.new(resource_params)
existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save
flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
@domain_block.errors[:domain].clear
render :new
else
if existing_domain_block.present?
@domain_block = existing_domain_block
@domain_block.update(resource_params)
end
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
render :new
end
end
end

View File

@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
skip_before_action :store_current_location
skip_before_action :check_user_permissions
before_action :set_cache_headers
protect_from_forgery with: :null_session
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
def authorize_if_got_token!(*scopes)
doorkeeper_authorize!(*scopes) if doorkeeper_token
end
def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
end
end

View File

@ -3,6 +3,8 @@
class Api::V1::CustomEmojisController < Api::BaseController
respond_to :json
skip_before_action :set_cache_headers
def index
render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)

View File

@ -2,6 +2,7 @@
class Api::V1::Instances::ActivityController < Api::BaseController
before_action :require_enabled_api!
skip_before_action :set_cache_headers
respond_to :json

View File

@ -2,6 +2,7 @@
class Api::V1::Instances::PeersController < Api::BaseController
before_action :require_enabled_api!
skip_before_action :set_cache_headers
respond_to :json

View File

@ -2,6 +2,7 @@
class Api::V1::InstancesController < Api::BaseController
respond_to :json
skip_before_action :set_cache_headers
def show
render_cached_json('api:v1:instances', expires_in: 5.minutes) do

View File

@ -91,7 +91,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def set_invite
@invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
@invite = invite&.valid_for_use? ? invite : nil
end
def determine_layout

View File

@ -25,7 +25,7 @@ class Settings::NotificationsController < Settings::BaseController
def user_settings_params
params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest report),
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
interactions: %i(must_be_follower must_be_following must_be_following_dm)
)
end

View File

@ -205,8 +205,8 @@ export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const total = Array.from(files).reduce((a, v) => a + v.size, 0);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
@ -226,6 +226,8 @@ export function uploadCompose(files) {
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
// Account for disparity in size of original image and resized data
total += file.size - f.size;
return api(getState).post('/api/v1/media', data, {
onUploadProgress: function({ loaded }){

View File

@ -96,7 +96,7 @@ export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done =
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {

View File

@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
import classNames from 'classnames';
import { autoPlayGif, displayMedia } from '../initial_state';
import { decode } from 'blurhash';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@ -21,6 +22,7 @@ class Item extends React.PureComponent {
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -29,6 +31,10 @@ class Item extends React.PureComponent {
size: 1,
};
state = {
loaded: false,
};
handleMouseEnter = (e) => {
if (this.hoverToPlay()) {
e.target.play();
@ -62,8 +68,40 @@ class Item extends React.PureComponent {
e.stopPropagation();
}
componentDidMount () {
if (this.props.attachment.get('blurhash')) {
this._decode();
}
}
componentDidUpdate (prevProps) {
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
this._decode();
}
}
_decode () {
const hash = this.props.attachment.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);
}
}
setCanvasRef = c => {
this.canvas = c;
}
handleImageLoad = () => {
this.setState({ loaded: true });
}
render () {
const { attachment, index, size, standalone, displayWidth } = this.props;
const { attachment, index, size, standalone, displayWidth, visible } = this.props;
let width = 50;
let height = 100;
@ -116,12 +154,20 @@ class Item extends React.PureComponent {
let thumbnail = '';
if (attachment.get('type') === 'image') {
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a>
</div>
);
} else if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
@ -147,6 +193,7 @@ class Item extends React.PureComponent {
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
</a>
);
@ -176,7 +223,8 @@ class Item extends React.PureComponent {
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
{visible && thumbnail}
</div>
);
}
@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
this.setState({
width: node.offsetWidth,
});
@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
const width = this.state.width || defaultWidth;
let children;
let children, spoilerButton;
const style = {};
@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
style.height = height;
}
if (!visible) {
let warning;
const size = media.take(4).size;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
}
children = (
<button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
} else {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
</button>
);
} else {
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
}
}
return (
<div className='media-gallery' style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
{spoilerButton}
</div>
{children}

View File

@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
if (this.props.muted) {
media = (
<AttachmentList
compact
@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
{Component => (
<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={this.props.cachedMediaWidth}

View File

@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
handleMoveUp = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, true);
}
handleMoveDown = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, false);
}
handleLoadOlder = debounce(() => {
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
}, 300, { leading: true })
_selectChild (index) {
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}

View File

@ -1,62 +1,142 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Permalink from '../../../components/permalink';
import { displayMedia } from '../../../initial_state';
import Icon from 'mastodon/components/icon';
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
import classNames from 'classnames';
import { decode } from 'blurhash';
import { isIOS } from 'mastodon/is_mobile';
export default class MediaItem extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
attachment: ImmutablePropTypes.map.isRequired,
displayWidth: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
};
state = {
visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
loaded: false,
};
handleClick = () => {
if (!this.state.visible) {
this.setState({ visible: true });
return true;
componentDidMount () {
if (this.props.attachment.get('blurhash')) {
this._decode();
}
}
return false;
componentDidUpdate (prevProps) {
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
this._decode();
}
}
_decode () {
const hash = this.props.attachment.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);
}
}
setCanvasRef = c => {
this.canvas = c;
}
handleImageLoad = () => {
this.setState({ loaded: true });
}
handleMouseEnter = e => {
if (this.hoverToPlay()) {
e.target.play();
}
}
handleMouseLeave = e => {
if (this.hoverToPlay()) {
e.target.pause();
e.target.currentTime = 0;
}
}
hoverToPlay () {
return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
}
handleClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (this.state.visible) {
this.props.onOpenMedia(this.props.attachment);
} else {
this.setState({ visible: true });
}
}
}
render () {
const { media } = this.props;
const { visible } = this.state;
const status = media.get('status');
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const style = {};
const { attachment, displayWidth } = this.props;
const { visible, loaded } = this.state;
let label, icon;
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const height = width;
const status = attachment.get('status');
if (media.get('type') === 'gifv') {
label = <span className='media-gallery__gifv__label'>GIF</span>;
}
let thumbnail = '';
if (visible) {
style.backgroundImage = `url(${media.get('preview_url')})`;
style.backgroundPosition = `${x}% ${y}%`;
} else {
icon = (
<span className='account-gallery__item__icons'>
<Icon id='eye-slash' />
</span>
if (attachment.get('type') === 'unknown') {
// Skip
} else if (attachment.get('type') === 'image') {
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<img
src={attachment.get('preview_url')}
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
);
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video
className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')}
title={attachment.get('description')}
role='application'
src={attachment.get('url')}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay}
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<div className='account-gallery__item'>
<Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
{icon}
{label}
</Permalink>
<div className='account-gallery__item' style={{ width, height }}>
<a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick}>
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
{visible && thumbnail}
</a>
</div>
);
}

View File

@ -2,24 +2,25 @@ import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { fetchAccount } from '../../actions/accounts';
import { fetchAccount } from 'mastodon/actions/accounts';
import { expandAccountMediaTimeline } from '../../actions/timelines';
import LoadingIndicator from '../../components/loading_indicator';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
import ColumnBackButton from 'mastodon/components/column_back_button';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { getAccountGallery } from '../../selectors';
import { getAccountGallery } from 'mastodon/selectors';
import MediaItem from './components/media_item';
import HeaderContainer from '../account_timeline/containers/header_container';
import { ScrollContainer } from 'react-router-scroll-4';
import LoadMore from '../../components/load_more';
import LoadMore from 'mastodon/components/load_more';
import MissingIndicator from 'mastodon/components/missing_indicator';
import { openModal } from 'mastodon/actions/modal';
const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]),
medias: getAccountGallery(state, props.params.accountId),
attachments: getAccountGallery(state, props.params.accountId),
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
});
class LoadMoreMedia extends ImmutablePureComponent {
@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
medias: ImmutablePropTypes.list.isRequired,
attachments: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
isAccount: PropTypes.bool,
};
state = {
width: 323,
};
componentDidMount () {
this.props.dispatch(fetchAccount(this.props.params.accountId));
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
handleScrollToBottom = () => {
if (this.props.hasMore) {
this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
}
}
handleScroll = (e) => {
handleScroll = e => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
};
handleLoadOlder = (e) => {
handleLoadOlder = e => {
e.preventDefault();
this.handleScrollToBottom();
}
handleOpenMedia = attachment => {
if (attachment.get('type') === 'video') {
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
} else {
const media = attachment.getIn(['status', 'media_attachments']);
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
}
}
handleRef = c => {
if (c) {
this.setState({ width: c.offsetWidth });
}
}
render () {
const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
const { width } = this.state;
if (!isAccount) {
return (
@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent {
);
}
let loadOlder = null;
if (!medias && isLoading) {
if (!attachments && isLoading) {
return (
<Column>
<LoadingIndicator />
@ -114,7 +135,9 @@ class AccountGallery extends ImmutablePureComponent {
);
}
if (hasMore && !(isLoading && medias.size === 0)) {
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
}
@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent {
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
<HeaderContainer accountId={this.props.params.accountId} />
<div role='feed' className='account-gallery__container'>
{medias.map((media, index) => media === null ? (
<LoadMoreMedia
key={'more:' + medias.getIn(index + 1, 'id')}
maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
onLoadMore={this.handleLoadMore}
/>
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem
key={media.get('id')}
media={media}
/>
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
))}
{loadOlder}
</div>
{isLoading && medias.size === 0 && (
{isLoading && attachments.size === 0 && (
<div className='scrollable__append'>
<LoadingIndicator />
</div>

View File

@ -11,7 +11,6 @@ import { defineMessages, injectIntl } from 'react-intl';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import FederationDropdownContainer from '../containers/federation_dropdown_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container';
@ -41,18 +40,17 @@ class ComposeForm extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
text: PropTypes.string.isRequired,
suggestion_token: PropTypes.string,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
federation: PropTypes.bool,
spoiler_text: PropTypes.string,
spoilerText: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
preselectDate: PropTypes.instanceOf(Date),
is_submitting: PropTypes.bool,
is_changing_upload: PropTypes.bool,
is_uploading: PropTypes.bool,
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isUploading: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
@ -87,10 +85,10 @@ class ComposeForm extends ImmutablePureComponent {
}
// Submit disabled:
const { is_submitting, is_changing_upload, is_uploading, anyMedia } = this.props;
const fulltext = [this.props.spoiler_text, countableText(this.props.text)].join('');
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
const fulltext = [this.props.spoilerText, countableText(this.props.text)].join('');
if (is_submitting || is_uploading || is_changing_upload || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
@ -135,7 +133,7 @@ class ComposeForm extends ImmutablePureComponent {
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
} else if(prevProps.is_submitting && !this.props.is_submitting) {
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
this.autosuggestTextarea.textarea.focus();
} else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
@ -164,9 +162,9 @@ class ComposeForm extends ImmutablePureComponent {
render () {
const { intl, onPaste, showSearch, anyMedia } = this.props;
const disabled = this.props.is_submitting;
const text = [this.props.spoiler_text, countableText(this.props.text)].join('');
const disabledButton = disabled || this.props.is_uploading || this.props.is_changing_upload || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
let publishText = '';
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
@ -184,7 +182,7 @@ class ComposeForm extends ImmutablePureComponent {
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} tabIndex={this.props.spoiler ? 0 : -1} type='text' className='spoiler-input__input' id='cw-spoiler-input' ref={this.setSpoilerText} />
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoilerText} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} tabIndex={this.props.spoiler ? 0 : -1} type='text' className='spoiler-input__input' id='cw-spoiler-input' ref={this.setSpoilerText} />
</label>
</div>
@ -217,7 +215,6 @@ class ComposeForm extends ImmutablePureComponent {
<UploadButtonContainer />
<PollButtonContainer />
<PrivacyDropdownContainer />
<SensitiveButtonContainer />
<SpoilerButtonContainer />
<FederationDropdownContainer />
</div>

View File

@ -26,6 +26,7 @@ class Option extends React.PureComponent {
isPollMultiple: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onToggleMultiple: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -37,13 +38,24 @@ class Option extends React.PureComponent {
this.props.onRemove(this.props.index);
};
handleToggleMultiple = e => {
this.props.onToggleMultiple();
e.preventDefault();
e.stopPropagation();
};
render () {
const { isPollMultiple, title, index, intl } = this.props;
return (
<li>
<label className='poll__text editable'>
<span className={classNames('poll__input', { checkbox: isPollMultiple })} />
<span
className={classNames('poll__input', { checkbox: isPollMultiple })}
onClick={this.handleToggleMultiple}
role='button'
tabIndex='0'
/>
<input
type='text'
@ -86,6 +98,10 @@ class PollForm extends ImmutablePureComponent {
this.props.onChangeSettings(e.target.value, this.props.isMultiple);
};
handleToggleMultiple = () => {
this.props.onChangeSettings(this.props.expiresIn, !this.props.isMultiple);
};
render () {
const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props;
@ -96,7 +112,7 @@ class PollForm extends ImmutablePureComponent {
return (
<div className='compose-form__poll-wrapper'>
<ul>
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} />)}
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} />)}
</ul>
<div className='poll__footer'>

View File

@ -73,7 +73,7 @@ class Search extends React.PureComponent {
}
}
handleKeyDown = (e) => {
handleKeyUp = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.props.onSubmit();
@ -82,10 +82,6 @@ class Search extends React.PureComponent {
}
}
noop () {
}
handleFocus = () => {
this.setState({ expanded: true });
this.props.onShow();
@ -110,7 +106,7 @@ class Search extends React.PureComponent {
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>

View File

@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import UploadProgressContainer from '../containers/upload_progress_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import UploadContainer from '../containers/upload_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
export default class UploadForm extends ImmutablePureComponent {
@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent {
<UploadContainer id={id} key={id} />
))}
</div>
{!mediaIds.isEmpty() && <SensitiveButtonContainer />}
</div>
);
}

View File

@ -1,6 +1,5 @@
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import { uploadCompose } from '../../../actions/compose';
import {
changeCompose,
submitCompose,
@ -9,22 +8,22 @@ import {
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from '../../../actions/compose';
const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
federation: state.getIn(['compose', 'federation']),
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
is_uploading: state.getIn(['compose', 'is_uploading']),
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
});
@ -47,8 +46,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId) {
dispatch(selectComposeSuggestion(position, token, accountId));
onSuggestionSelected (position, token, suggestion) {
dispatch(selectComposeSuggestion(position, token, suggestion));
},
onChangeSpoilerText (checked) {

View File

@ -2,11 +2,9 @@ import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import IconButton from '../../../components/icon_button';
import { changeComposeSensitivity } from '../../../actions/compose';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { injectIntl, defineMessages } from 'react-intl';
import { changeComposeSensitivity } from 'mastodon/actions/compose';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
@ -14,7 +12,6 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
visible: state.getIn(['compose', 'media_attachments']).size > 0,
active: state.getIn(['compose', 'sensitive']),
disabled: state.getIn(['compose', 'spoiler']),
});
@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({
class SensitiveButton extends React.PureComponent {
static propTypes = {
visible: PropTypes.bool,
active: PropTypes.bool,
disabled: PropTypes.bool,
onClick: PropTypes.func.isRequired,
@ -38,32 +34,14 @@ class SensitiveButton extends React.PureComponent {
};
render () {
const { visible, active, disabled, onClick, intl } = this.props;
const { active, disabled, onClick, intl } = this.props;
return (
<Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
{({ scale }) => {
const icon = active ? 'eye-slash' : 'eye';
const className = classNames('compose-form__sensitive-button', {
'compose-form__sensitive-button--visible': visible,
});
return (
<div className={className} style={{ transform: `scale(${scale})` }}>
<IconButton
className='compose-form__sensitive-button__icon'
title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
icon={icon}
onClick={onClick}
size={18}
active={active}
disabled={disabled}
style={{ lineHeight: null, height: null }}
inverted
/>
</div>
);
}}
</Motion>
<div className='compose-form__sensitive-button'>
<button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
<Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
</button>
</div>
);
}

View File

@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent {
handleMoveUp = id => {
const elementIndex = this.getCurrentIndex(id) - 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, true);
}
handleMoveDown = id => {
const elementIndex = this.getCurrentIndex(id) + 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, false);
}
_selectChild (index) {
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}

View File

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, invitesEnabled, version, profile_directory } from '../../initial_state';
import { me, invitesEnabled, version, profile_directory, repository, source_url } from '../../initial_state';
import { fetchFollowRequests } from '../../actions/accounts';
import { List as ImmutableList } from 'immutable';
import { Link } from 'react-router-dom';
@ -172,7 +172,7 @@ class GettingStarted extends ImmutablePureComponent {
<FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
values={{ github: <span><a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> (v{version})</span> }}
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
/>
</p>
</div>

View File

@ -113,18 +113,24 @@ class Notifications extends React.PureComponent {
handleMoveUp = id => {
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, true);
}
handleMoveDown = id => {
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
this._selectChild(elementIndex);
this._selectChild(elementIndex, false);
}
_selectChild (index) {
const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
_selectChild (index, align_top) {
const container = this.column.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}

View File

@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
{Component => (
<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={239}

View File

@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import AttachmentList from '../../../components/attachment_list';
import { Link } from 'react-router-dom';
import { defineMessages, injectIntl, FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card';
@ -116,14 +115,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = <AttachmentList media={status.get('media_attachments')} />;
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Video
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={300}

View File

@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size - 1);
this._selectChild(ancestorsIds.size - 1, true);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index);
this._selectChild(ancestorsIds.size + index, true);
} else {
this._selectChild(index - 1);
this._selectChild(index - 1, true);
}
}
}
@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size + 1);
this._selectChild(ancestorsIds.size + 1, false);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index + 2);
this._selectChild(ancestorsIds.size + index + 2, false);
} else {
this._selectChild(index + 1);
this._selectChild(index + 1, false);
}
}
}
_selectChild (index) {
const element = this.node.querySelectorAll('.focusable')[index];
_selectChild (index, align_top) {
const container = this.node;
const element = container.querySelectorAll('.focusable')[index];
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}

View File

@ -64,7 +64,7 @@ export default class ActionsModal extends ImmutablePureComponent {
<div className='modal-root__modal actions-modal'>
{status}
<ul>
<ul className={classNames({ 'with-status': !!status })}>
{this.props.actions.map(this.renderAction)}
</ul>
</div>

View File

@ -2,11 +2,11 @@ import React from 'react';
import ReactSwipeableViews from 'react-swipeable-views';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Video from '../../video';
import ExtendedVideoPlayer from '../../../components/extended_video_player';
import Video from 'mastodon/features/video';
import ExtendedVideoPlayer from 'mastodon/components/extended_video_player';
import classNames from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from 'mastodon/components/icon_button';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImageLoader from './image_loader';
import Icon from 'mastodon/components/icon';
@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.list.isRequired,
status: ImmutablePropTypes.map,
index: PropTypes.number.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent {
componentDidMount () {
window.addEventListener('keydown', this.handleKeyDown, false);
if (this.context.router) {
const history = this.context.router.history;
history.push(history.location.pathname, previewState);
this.unlistenHistory = history.listen(() => {
this.props.onClose();
});
@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent {
componentWillUnmount () {
window.removeEventListener('keydown', this.handleKeyDown);
if (this.context.router) {
this.unlistenHistory();
@ -102,8 +107,15 @@ class MediaModal extends ImmutablePureComponent {
}));
};
handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
}
render () {
const { media, intl, onClose } = this.props;
const { media, status, intl, onClose } = this.props;
const { navigationHidden } = this.state;
const index = this.getIndex();
@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent {
return (
<Video
preview={image.get('preview_url')}
blurhash={image.get('blurhash')}
src={image.get('url')}
width={image.get('width')}
height={image.get('height')}
@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent {
{content}
</ReactSwipeableViews>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
{leftNav}
{rightNav}
{status && (
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
<a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
</div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>

View File

@ -1,28 +1,69 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Video from '../../video';
import Video from 'mastodon/features/video';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
export const previewState = 'previewVideoModal';
export default class VideoModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
time: PropTypes.number,
onClose: PropTypes.func.isRequired,
};
static contextTypes = {
router: PropTypes.object,
};
componentDidMount () {
if (this.context.router) {
const history = this.context.router.history;
history.push(history.location.pathname, previewState);
this.unlistenHistory = history.listen(() => {
this.props.onClose();
});
}
}
componentWillUnmount () {
if (this.context.router) {
this.unlistenHistory();
if (this.context.router.history.location.state === previewState) {
this.context.router.history.goBack();
}
}
}
handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
}
render () {
const { media, time, onClose } = this.props;
const { media, status, time, onClose } = this.props;
const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
return (
<div className='modal-root__modal video-modal'>
<div>
<Video
preview={media.get('preview_url')}
blurhash={media.get('blurhash')}
src={media.get('url')}
startTime={time}
onCloseVideo={onClose}
link={link}
detailed
alt={media.get('description')}
/>

View File

@ -47,7 +47,8 @@ import {
Lists,
} from './util/async-components';
import { me } from '../../initial_state';
import { previewState } from './components/media_modal';
import { previewState as previewMediaState } from './components/media_modal';
import { previewState as previewVideoState } from './components/video_modal';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
@ -121,7 +122,7 @@ class SwitchingColumnsArea extends React.PureComponent {
}
shouldUpdateScroll (_, { location }) {
return location.state !== previewState;
return location.state !== previewMediaState && location.state !== previewVideoState;
}
handleResize = debounce(() => {
@ -367,11 +368,16 @@ class UI extends React.PureComponent {
handleHotkeyFocusColumn = e => {
const index = (e.key * 1) + 1; // First child is drawer, skip that
const column = this.node.querySelector(`.column:nth-child(${index})`);
if (!column) return;
const container = column.querySelector('.scrollable');
if (column) {
const status = column.querySelector('.focusable');
if (container) {
const status = container.querySelector('.focusable');
if (status) {
if (container.scrollTop > status.offsetTop) {
status.scrollIntoView(true);
}
status.focus();
}
}

View File

@ -7,6 +7,7 @@ import classNames from 'classnames';
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
import { displayMedia } from '../../initial_state';
import Icon from 'mastodon/components/icon';
import { decode } from 'blurhash';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
@ -102,6 +103,8 @@ class Video extends React.PureComponent {
inline: PropTypes.bool,
cacheWidth: PropTypes.func,
intl: PropTypes.object.isRequired,
blurhash: PropTypes.string,
link: PropTypes.node,
};
state = {
@ -139,6 +142,7 @@ class Video extends React.PureComponent {
setVideoRef = c => {
this.video = c;
if (this.video) {
this.setState({ volume: this.video.volume, muted: this.video.muted });
}
@ -152,6 +156,10 @@ class Video extends React.PureComponent {
this.volume = c;
}
setCanvasRef = c => {
this.canvas = c;
}
handleClickRoot = e => e.stopPropagation();
handlePlay = () => {
@ -170,7 +178,6 @@ class Video extends React.PureComponent {
}
handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
@ -190,7 +197,6 @@ 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.
@ -261,6 +267,10 @@ class Video extends React.PureComponent {
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
if (this.props.blurhash) {
this._decode();
}
}
componentWillUnmount () {
@ -270,6 +280,24 @@ class Video extends React.PureComponent {
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
}
componentDidUpdate (prevProps) {
if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
this._decode();
}
}
_decode () {
const hash = this.props.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);
}
}
handleFullscreenChange = () => {
this.setState({ fullscreen: isFullscreen() });
}
@ -314,6 +342,7 @@ class Video extends React.PureComponent {
handleOpenVideo = () => {
const { src, preview, width, height, alt } = this.props;
const media = fromJS({
type: 'video',
url: src,
@ -333,7 +362,7 @@ class Video extends React.PureComponent {
}
render () {
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props;
const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = (currentTime / duration) * 100;
@ -351,6 +380,7 @@ class Video extends React.PureComponent {
}
let preload;
if (startTime || fullscreen || dragging) {
preload = 'auto';
} else if (detailed) {
@ -360,6 +390,7 @@ class Video extends React.PureComponent {
}
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
@ -377,7 +408,9 @@ class Video extends React.PureComponent {
onClick={this.handleClickRoot}
tabIndex={0}
>
<video
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
{revealed && <video
ref={this.setVideoRef}
src={src}
poster={preview}
@ -397,12 +430,13 @@ class Video extends React.PureComponent {
onLoadedData={this.handleLoadedData}
onProgress={this.handleProgress}
onVolumeChange={this.handleVolumeChange}
/>
/>}
<button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
<span className='video-player__spoiler__title'>{warning}</span>
<span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button>
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
<span className='spoiler-button__overlay__label'>{warning}</span>
</button>
</div>
<div className={classNames('video-player__controls', { active: paused || hovered })}>
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
@ -420,6 +454,7 @@ class Video extends React.PureComponent {
<div className='video-player__buttons left'>
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
<span
@ -429,17 +464,19 @@ class Video extends React.PureComponent {
/>
</div>
{(detailed || fullscreen) &&
{(detailed || fullscreen) && (
<span>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(duration)}</span>
</span>
}
)}
{link && <span className='video-player__link'>{link}</span>}
</div>
<div className='video-player__buttons right'>
{!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>}
{!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
{(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
{onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
<button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>

View File

@ -13,6 +13,8 @@ export const deleteModal = getMeta('delete_modal');
export const me = getMeta('me');
export const searchEnabled = getMeta('search_enabled');
export const invitesEnabled = getMeta('invites_enabled');
export const repository = getMeta('repository');
export const source_url = getMeta('source_url');
export const version = getMeta('version');
export const mascot = getMeta('mascot');
export const profile_directory = getMeta('profile_directory');

View File

@ -77,6 +77,7 @@
"compose_form.poll.remove_option": "Odstranit tuto volbu",
"compose_form.publish": "Tootnout",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive.hide": "Označit média jako citlivá",
"compose_form.sensitive.marked": "Média jsou označena jako citlivá",
"compose_form.sensitive.unmarked": "Média nejsou označena jako citlivá",
"compose_form.spoiler.marked": "Text je skrytý za varováním",
@ -214,6 +215,7 @@
"lightbox.close": "Zavřít",
"lightbox.next": "Další",
"lightbox.previous": "Předchozí",
"lightbox.view_context": "Zobrazit kontext",
"lists.account.add": "Přidat do seznamu",
"lists.account.remove": "Odebrat ze seznamu",
"lists.delete": "Smazat seznam",

View File

@ -0,0 +1,384 @@
{
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.badges.bot": "Bot",
"account.block": "Block @{name}",
"account.block_domain": "Hide everything from {domain}",
"account.blocked": "Blocked",
"account.direct": "Direct message @{name}",
"account.domain_blocked": "Domain hidden",
"account.edit_profile": "Edit profile",
"account.endorse": "Feature on profile",
"account.follow": "Follow",
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.follows": "Follows",
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.follows_you": "Follows you",
"account.hide_reblogs": "Hide boosts from @{name}",
"account.link_verified_on": "Ownership of this link was checked on {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": "Mention @{name}",
"account.moved_to": "{name} has moved to:",
"account.mute": "Mute @{name}",
"account.mute_notifications": "Mute notifications from @{name}",
"account.muted": "Muted",
"account.posts": "Toots",
"account.posts_with_replies": "Toots and replies",
"account.report": "Report @{name}",
"account.requested": "Awaiting approval. Click to cancel follow request",
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show boosts from @{name}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unhide {domain}",
"account.unendorse": "Don't feature on profile",
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"alert.unexpected.message": "An unexpected error occurred.",
"alert.unexpected.title": "Oops!",
"boost_modal.combo": "You can press {combo} to skip this next time",
"bundle_column_error.body": "Something went wrong while loading this component.",
"bundle_column_error.retry": "Try again",
"bundle_column_error.title": "Network error",
"bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.",
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"column.community": "Local timeline",
"column.direct": "Direct messages",
"column.domain_blocks": "Hidden domains",
"column.favourites": "Favourites",
"column.follow_requests": "Follow requests",
"column.home": "Home",
"column.lists": "Lists",
"column.mutes": "Muted users",
"column.notifications": "Notifications",
"column.pins": "Pinned toot",
"column.public": "Federated timeline",
"column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings",
"column_header.moveLeft_settings": "Move column to the left",
"column_header.moveRight_settings": "Move column to the right",
"column_header.pin": "Pin",
"column_header.show_settings": "Show settings",
"column_header.unpin": "Unpin",
"column_subheading.settings": "Settings",
"community.column_settings.media_only": "Media Only",
"compose_form.direct_message_warning": "This toot will only be sent to all the mentioned users.",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.placeholder": "What is on your mind?",
"compose_form.poll.add_option": "Add a choice",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.option_placeholder": "Choice {number}",
"compose_form.poll.remove_option": "Remove this choice",
"compose_form.publish": "Toot",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive.marked": "Media is marked as sensitive",
"compose_form.sensitive.unmarked": "Media is not marked as sensitive",
"compose_form.spoiler.marked": "Text is hidden behind warning",
"compose_form.spoiler.unmarked": "Text is not hidden",
"compose_form.spoiler_placeholder": "Write your warning here",
"confirmation_modal.cancel": "Cancel",
"confirmations.block.block_and_report": "Block & Report",
"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_list.confirm": "Delete",
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
"confirmations.domain_block.confirm": "Hide entire domain",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
"confirmations.mute.confirm": "Mute",
"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.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",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"embed.instructions": "Embed this status 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",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
"emoji_button.label": "Insert emoji",
"emoji_button.nature": "Nature",
"emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
"emoji_button.objects": "Objects",
"emoji_button.people": "People",
"emoji_button.recent": "Frequently used",
"emoji_button.search": "Search...",
"emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.account_timeline": "No toots here!",
"empty_column.account_unavailable": "Profile unavailable",
"empty_column.blocks": "You haven't blocked any users yet.",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.domain_blocks": "There are no hidden domains yet.",
"empty_column.favourited_statuses": "You don't have any favourite toots yet. When you favourite one, it will show up here.",
"empty_column.favourites": "No one has favourited this toot yet. When someone does, they will show up here.",
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
"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.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.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"follow_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"getting_started.developers": "Developers",
"getting_started.directory": "Profile directory",
"getting_started.documentation": "Documentation",
"getting_started.heading": "Getting started",
"getting_started.invite": "Invite people",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
"getting_started.security": "Security",
"getting_started.terms": "Terms of service",
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
"hashtag.column_settings.select.no_options_message": "No suggestions found",
"hashtag.column_settings.select.placeholder": "Enter hashtags…",
"hashtag.column_settings.tag_mode.all": "All of these",
"hashtag.column_settings.tag_mode.any": "Any of these",
"hashtag.column_settings.tag_mode.none": "None of these",
"hashtag.column_settings.tag_toggle": "Include additional tags in this column",
"home.column_settings.basic": "Basic",
"home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies",
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
"introduction.federation.action": "Next",
"introduction.federation.federated.headline": "Federated",
"introduction.federation.federated.text": "Public posts from other servers of the fediverse will appear in the federated timeline.",
"introduction.federation.home.headline": "Home",
"introduction.federation.home.text": "Posts from people you follow will appear in your home feed. You can follow anyone on any server!",
"introduction.federation.local.headline": "Local",
"introduction.federation.local.text": "Public posts from people on the same server as you will appear in the local timeline.",
"introduction.interactions.action": "Finish toot-orial!",
"introduction.interactions.favourite.headline": "Favourite",
"introduction.interactions.favourite.text": "You can save a toot for later, and let the author know that you liked it, by favouriting it.",
"introduction.interactions.reblog.headline": "Boost",
"introduction.interactions.reblog.text": "You can share other people's toots with your followers by boosting them.",
"introduction.interactions.reply.headline": "Reply",
"introduction.interactions.reply.text": "You can reply to other people's and your own toots, which will chain them together in a conversation.",
"introduction.welcome.action": "Let's go!",
"introduction.welcome.headline": "First steps",
"introduction.welcome.text": "Welcome to the fediverse! In a few moments, you'll be able to broadcast messages and talk to your friends across a wide variety of servers. But this server, {domain}, is special—it hosts your profile, so remember its name.",
"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.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.favourite": "to favourite",
"keyboard_shortcuts.favourites": "to open favourites list",
"keyboard_shortcuts.federated": "to open federated timeline",
"keyboard_shortcuts.heading": "Keyboard Shortcuts",
"keyboard_shortcuts.home": "to open home timeline",
"keyboard_shortcuts.hotkey": "Hotkey",
"keyboard_shortcuts.legend": "to display this legend",
"keyboard_shortcuts.local": "to open local timeline",
"keyboard_shortcuts.mention": "to mention author",
"keyboard_shortcuts.muted": "to open muted users list",
"keyboard_shortcuts.my_profile": "to open your profile",
"keyboard_shortcuts.notifications": "to open notifications column",
"keyboard_shortcuts.pinned": "to open pinned toots list",
"keyboard_shortcuts.profile": "to open author's profile",
"keyboard_shortcuts.reply": "to reply",
"keyboard_shortcuts.requests": "to open follow requests list",
"keyboard_shortcuts.search": "to focus search",
"keyboard_shortcuts.start": "to open \"get started\" column",
"keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
"keyboard_shortcuts.toot": "to start a brand new toot",
"keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
"keyboard_shortcuts.up": "to move up in the list",
"lightbox.close": "Close",
"lightbox.next": "Next",
"lightbox.previous": "Previous",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.delete": "Delete list",
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title",
"lists.search": "Search among people you follow",
"lists.subheading": "Your lists",
"loading_indicator.label": "Loading...",
"media_gallery.toggle_visible": "Toggle visibility",
"missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found",
"mute_modal.hide_notifications": "Hide notifications from this user?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.compose": "Compose new toot",
"navigation_bar.direct": "Direct messages",
"navigation_bar.discover": "Discover",
"navigation_bar.domain_blocks": "Hidden domains",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.favourites": "Favourites",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "About this server",
"navigation_bar.keyboard_shortcuts": "Hotkeys",
"navigation_bar.lists": "Lists",
"navigation_bar.logout": "Logout",
"navigation_bar.mutes": "Muted users",
"navigation_bar.personal": "Personal",
"navigation_bar.pins": "Pinned toots",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.security": "Security",
"notification.favourite": "{name} favourited your status",
"notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your status",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications",
"notifications.column_settings.favourite": "Favourites:",
"notifications.column_settings.filter_bar.advanced": "Display all categories",
"notifications.column_settings.filter_bar.category": "Quick filter bar",
"notifications.column_settings.filter_bar.show": "Show",
"notifications.column_settings.follow": "New followers:",
"notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.poll": "Poll results:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound",
"notifications.filter.all": "All",
"notifications.filter.boosts": "Boosts",
"notifications.filter.favourites": "Favourites",
"notifications.filter.follows": "Follows",
"notifications.filter.mentions": "Mentions",
"notifications.filter.polls": "Poll results",
"notifications.group": "{count} notifications",
"poll.closed": "Closed",
"poll.refresh": "Refresh",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote",
"poll_button.add_poll": "Add a poll",
"poll_button.remove_poll": "Remove poll",
"privacy.change": "Adjust status privacy",
"privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct",
"privacy.private.long": "Post to followers only",
"privacy.private.short": "Followers-only",
"privacy.public.long": "Post to public timelines",
"privacy.public.short": "Public",
"privacy.unlisted.long": "Do not show in public timelines",
"privacy.unlisted.short": "Unlisted",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.just_now": "now",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"reply_indicator.cancel": "Cancel",
"report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
"report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:",
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Report {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.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"search_results.accounts": "People",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Toots",
"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.block": "Block @{name}",
"status.cancel_reblog_private": "Unboost",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to status",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
"status.embed": "Embed",
"status.favourite": "Favourite",
"status.filtered": "Filtered",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}",
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
"status.pin": "Pin on profile",
"status.pinned": "Pinned toot",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost to original audience",
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",
"status.reply": "Reply",
"status.replyAll": "Reply to thread",
"status.report": "Report @{name}",
"status.sensitive_toggle": "Click to view",
"status.sensitive_warning": "Sensitive content",
"status.share": "Share",
"status.show_less": "Show less",
"status.show_less_all": "Show less for all",
"status.show_more": "Show more",
"status.show_more_all": "Show more for all",
"status.show_thread": "Show thread",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"suggestions.dismiss": "Dismiss suggestion",
"suggestions.header": "You might be interested in…",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Home",
"tabs_bar.local_timeline": "Local",
"tabs_bar.notifications": "Notifications",
"tabs_bar.search": "Search",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"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",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.description": "Describe for the visually impaired",
"upload_form.focus": "Crop",
"upload_form.undo": "Delete",
"upload_progress.label": "Uploading...",
"video.close": "Close video",
"video.exit_fullscreen": "Exit full screen",
"video.expand": "Expand video",
"video.fullscreen": "Full screen",
"video.hide": "Hide video",
"video.mute": "Mute sound",
"video.pause": "Pause",
"video.play": "Play",
"video.unmute": "Unmute sound"
}

View File

@ -244,11 +244,11 @@
"navigation_bar.lists": "Ցանկեր",
"navigation_bar.logout": "Դուրս գալ",
"navigation_bar.mutes": "Լռեցրած օգտատերեր",
"navigation_bar.personal": "Personal",
"navigation_bar.personal": "Անձնական",
"navigation_bar.pins": "Ամրացված թթեր",
"navigation_bar.preferences": "Նախապատվություններ",
"navigation_bar.public_timeline": "Դաշնային հոսք",
"navigation_bar.security": "Security",
"navigation_bar.security": "Անվտանգություն",
"notification.favourite": "{name} հավանեց թութդ",
"notification.follow": "{name} սկսեց հետեւել քեզ",
"notification.mention": "{name} նշեց քեզ",
@ -314,7 +314,7 @@
"search_results.accounts": "People",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Toots",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}",
"status.admin_account": "Open moderation interface for @{name}",
"status.admin_status": "Open this status in the moderation interface",
"status.block": "Արգելափակել @{name}֊ին",

View File

@ -11,7 +11,7 @@
"account.follow": "Volgen",
"account.followers": "Volgers",
"account.followers.empty": "Niemand volgt nog deze gebruiker.",
"account.follows": "Volgt",
"account.follows": "Volgend",
"account.follows.empty": "Deze gebruiker volgt nog niemand.",
"account.follows_you": "Volgt jou",
"account.hide_reblogs": "Verberg boosts van @{name}",

View File

@ -117,7 +117,7 @@
"emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viagens & Lugares",
"empty_column.account_timeline": "Não há toots aqui!",
"empty_column.account_unavailable": "Profile unavailable",
"empty_column.account_unavailable": "Perfil indisponível",
"empty_column.blocks": "Você ainda não bloqueou nenhum usuário.",
"empty_column.community": "A timeline local está vazia. Escreva algo publicamente para começar!",
"empty_column.direct": "Você não tem nenhuma mensagem direta ainda. Quando você enviar ou receber uma, as mensagens aparecerão por aqui.",

View File

@ -118,7 +118,6 @@
"emoji_button.travel": "Путешествия",
"empty_column.account_timeline": "Статусов нет!",
"empty_column.account_unavailable": "Профиль недоступен",
"empty_column.account_timeline_blocked": "Вы заблокированы",
"empty_column.blocks": "Вы ещё никого не заблокировали.",
"empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!",
"empty_column.direct": "У Вас пока нет личных сообщений. Когда Вы начнёте их отправлять или получать, они появятся здесь.",

View File

@ -0,0 +1,2 @@
[
]

View File

@ -173,6 +173,21 @@ function main() {
avatar.src = url;
});
const getProfileAvatarAnimationHandler = (swapTo) => {
//animate avatar gifs on the profile page when moused over
return ({ target }) => {
const swapSrc = target.getAttribute(swapTo);
//only change the img source if autoplay is off and the image src is actually different
if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) {
target.src = swapSrc;
}
};
};
delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
delegate(document, '#account_header', 'change', ({ target }) => {
const header = document.querySelector('.card .card__img img');
const [file] = target.files || [];

View File

@ -10,7 +10,7 @@
@font-face {
font-family: 'mastodon-font-display';
src: local('Montserrat'),
src: local('Montserrat Medium'),
url('../fonts/montserrat/Montserrat-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;

View File

@ -1,6 +1,6 @@
@font-face {
font-family: 'mastodon-font-sans-serif';
src: local('Roboto'),
src: local('Roboto Italic'),
url('../fonts/roboto/roboto-italic-webfont.woff2') format('woff2'),
url('../fonts/roboto/roboto-italic-webfont.woff') format('woff'),
url('../fonts/roboto/roboto-italic-webfont.ttf') format('truetype'),
@ -11,7 +11,7 @@
@font-face {
font-family: 'mastodon-font-sans-serif';
src: local('Roboto'),
src: local('Roboto Bold'),
url('../fonts/roboto/roboto-bold-webfont.woff2') format('woff2'),
url('../fonts/roboto/roboto-bold-webfont.woff') format('woff'),
url('../fonts/roboto/roboto-bold-webfont.ttf') format('truetype'),
@ -22,7 +22,7 @@
@font-face {
font-family: 'mastodon-font-sans-serif';
src: local('Roboto'),
src: local('Roboto Medium'),
url('../fonts/roboto/roboto-medium-webfont.woff2') format('woff2'),
url('../fonts/roboto/roboto-medium-webfont.woff') format('woff'),
url('../fonts/roboto/roboto-medium-webfont.ttf') format('truetype'),

View File

@ -50,6 +50,7 @@ $content-width: 840px;
color: $darker-text-color;
text-decoration: none;
transition: all 200ms linear;
transition-property: color, background-color;
border-radius: 4px 0 0 4px;
i.fa {
@ -60,6 +61,7 @@ $content-width: 840px;
color: $primary-text-color;
background-color: darken($ui-base-color, 5%);
transition: all 100ms linear;
transition-property: color, background-color;
}
&.selected {

View File

@ -264,6 +264,16 @@
.compose-form {
padding: 10px;
&__sensitive-button {
padding: 10px;
padding-top: 0;
.icon-button {
font-size: 14px;
font-weight: 500;
}
}
.compose-form__warning {
color: $inverted-text-color;
margin-bottom: 10px;
@ -1962,6 +1972,7 @@ a.account__display-name {
font-weight: 500;
border-bottom: 2px solid lighten($ui-base-color, 8%);
transition: all 50ms linear;
transition-property: border-bottom, background, color;
.fa {
font-weight: 400;
@ -2127,7 +2138,7 @@ a.account__display-name {
padding: 0;
border-radius: 30px;
background-color: $ui-base-color;
transition: all 0.2s ease;
transition: background-color 0.2s ease;
}
.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
@ -2180,7 +2191,6 @@ a.account__display-name {
}
.react-toggle-thumb {
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
position: absolute;
top: 1px;
left: 1px;
@ -2191,6 +2201,7 @@ a.account__display-name {
background-color: darken($simple-background-color, 2%);
box-sizing: border-box;
transition: all 0.25s ease;
transition-property: border-color, left;
}
.react-toggle--checked .react-toggle-thumb {
@ -2412,7 +2423,7 @@ a.account__display-name {
& > div {
background: rgba($base-shadow-color, 0.6);
border-radius: 4px;
border-radius: 8px;
padding: 12px 9px;
flex: 0 0 auto;
display: flex;
@ -2423,19 +2434,18 @@ a.account__display-name {
button,
a {
display: inline;
color: $primary-text-color;
color: $secondary-text-color;
background: transparent;
border: 0;
padding: 0 5px;
padding: 0 8px;
text-decoration: none;
opacity: 0.6;
font-size: 18px;
line-height: 18px;
&:hover,
&:active,
&:focus {
opacity: 1;
color: $primary-text-color;
}
}
@ -2932,15 +2942,49 @@ a.status-card.compact:hover {
}
.spoiler-button {
display: none;
left: 4px;
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
top: 4px;
z-index: 100;
&.spoiler-button--visible {
&--minified {
display: block;
left: 4px;
top: 4px;
width: auto;
height: auto;
}
&--hidden {
display: none;
}
&__overlay {
display: block;
background: transparent;
width: 100%;
height: 100%;
border: 0;
&__label {
display: inline-block;
background: rgba($base-overlay-background, 0.5);
border-radius: 8px;
padding: 8px 12px;
color: $primary-text-color;
font-weight: 500;
font-size: 14px;
}
&:hover,
&:focus,
&:active {
.spoiler-button__overlay__label {
background: rgba($base-overlay-background, 0.8);
}
}
}
}
@ -3509,6 +3553,7 @@ a.status-card.compact:hover {
display: inline-block;
opacity: 0;
transition: all 100ms linear;
transition-property: transform, opacity;
font-size: 18px;
width: 18px;
height: 18px;
@ -3728,6 +3773,31 @@ a.status-card.compact:hover {
pointer-events: none;
}
.media-modal__meta {
text-align: center;
position: absolute;
left: 0;
bottom: 20px;
width: 100%;
pointer-events: none;
&--shifted {
bottom: 62px;
}
a {
text-decoration: none;
font-weight: 500;
color: $ui-secondary-color;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
}
.media-modal__page-dot {
display: inline-block;
}
@ -3961,14 +4031,6 @@ a.status-card.compact:hover {
font-size: 14px;
}
.confirmation-modal {
max-width: 85vw;
@media screen and (min-width: 480px) {
max-width: 380px;
}
}
.mute-modal {
line-height: 24px;
}
@ -4093,6 +4155,11 @@ a.status-card.compact:hover {
ul {
overflow-y: auto;
flex-shrink: 0;
max-height: 80vh;
&.with-status {
max-height: calc(80vh - 75px);
}
li:empty {
margin: 0;
@ -4147,6 +4214,10 @@ a.status-card.compact:hover {
color: darken($lighter-text-color, 4%);
}
}
.confirmation-modal__secondary-button {
flex-shrink: 1;
}
}
.confirmation-modal__container,
@ -4199,6 +4270,7 @@ a.status-card.compact:hover {
pointer-events: none;
opacity: 0.9;
transition: opacity 0.1s ease;
line-height: 18px;
}
.media-gallery__gifv {
@ -4312,6 +4384,8 @@ a.status-card.compact:hover {
text-decoration: none;
color: $secondary-text-color;
line-height: 0;
position: relative;
z-index: 1;
&,
img {
@ -4324,6 +4398,21 @@ a.status-card.compact:hover {
}
}
.media-gallery__preview {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
z-index: 0;
background: $base-overlay-background;
&--hidden {
display: none;
}
}
.media-gallery__gifv {
height: 100%;
overflow: hidden;
@ -4619,6 +4708,23 @@ a.status-card.compact:hover {
}
}
&__link {
padding: 2px 10px;
a {
text-decoration: none;
font-size: 14px;
font-weight: 500;
color: $white;
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
}
}
&__seek {
cursor: pointer;
height: 24px;
@ -4711,62 +4817,18 @@ a.status-card.compact:hover {
.account-gallery__container {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 2px;
padding: 4px 2px;
}
.account-gallery__item {
flex-grow: 1;
width: 50%;
overflow: hidden;
border: none;
box-sizing: border-box;
display: block;
position: relative;
&::before {
content: "";
display: block;
padding-top: 100%;
}
a {
display: block;
width: calc(100% - 4px);
height: calc(100% - 4px);
margin: 2px;
top: 0;
left: 0;
background-color: $base-overlay-background;
background-size: cover;
background-position: center;
position: absolute;
color: $darker-text-color;
text-decoration: none;
border-radius: 4px;
&:hover,
&:active,
&:focus {
outline: 0;
color: $secondary-text-color;
&::before {
content: "";
display: block;
width: 100%;
height: 100%;
background: rgba($base-overlay-background, 0.3);
border-radius: 4px;
}
}
}
&__icons {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
}
border-radius: 4px;
overflow: hidden;
margin: 2px;
}
.notification__filter-bar,

View File

@ -533,6 +533,17 @@ code {
color: $error-value-color;
}
a {
display: inline-block;
color: $darker-text-color;
text-decoration: none;
&:hover {
color: $primary-text-color;
text-decoration: underline;
}
}
p {
margin-bottom: 15px;
}

View File

@ -4,7 +4,6 @@
&__img {
width: 100%;
height: 167px;
position: relative;
overflow: hidden;
border-radius: 4px 4px 0 0;

View File

@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
next if attachment['url'].blank?
href = Addressable::URI.parse(attachment['url']).normalize.to_s
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
media_attachments << media_attachment
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
end
def supported_blurhash?(blurhash)
components = blurhash.blank? ? nil : Blurhash.components(blurhash)
components.present? && components.none? { |comp| comp > 5 }
end
def skip_download?
return @skip_download if defined?(@skip_download)
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?

View File

@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
}.freeze
def self.default_key_transform

View File

@ -2,7 +2,7 @@
class ProofProvider::Keybase
BASE_URL = ENV.fetch('KEYBASE_BASE_URL', 'https://keybase.io')
DOMAIN = ENV.fetch('KEYBASE_DOMAIN', Rails.configuration.x.local_domain)
DOMAIN = ENV.fetch('KEYBASE_DOMAIN', Rails.configuration.x.web_domain)
class Error < StandardError; end

View File

@ -6,6 +6,7 @@ module LdapAuthenticable
def ldap_setup(_attributes)
self.confirmed_at = Time.now.utc
self.admin = false
self.external = true
save!
end

View File

@ -66,6 +66,7 @@ module Omniauthable
email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
password: Devise.friendly_token[0, 20],
agreement: true,
external: true,
account_attributes: {
username: ensure_unique_username(auth.uid),
display_name: display_name,

View File

@ -34,6 +34,7 @@ module PamAuthenticable
self.confirmed_at = Time.now.utc
self.admin = false
self.account = account
self.external = true
account.destroy! unless save
end

View File

@ -29,4 +29,11 @@ class DomainBlock < ApplicationRecord
def self.blocked?(domain)
where(domain: domain, severity: :suspend).exists?
end
def stricter_than?(other_block)
return true if suspend?
return false if other_block.suspend? && (silence? || noop?)
return false if other_block.silence? && noop?
(reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports)
end
end

View File

@ -18,6 +18,7 @@
# account_id :bigint(8)
# description :text
# scheduled_status_id :bigint(8)
# blurhash :string
#
class MediaAttachment < ApplicationRecord
@ -32,6 +33,11 @@ class MediaAttachment < ApplicationRecord
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
BLURHASH_OPTIONS = {
x_comp: 4,
y_comp: 4,
}.freeze
IMAGE_STYLES = {
original: {
pixels: 1_638_400, # 1280x1280px
@ -41,6 +47,7 @@ class MediaAttachment < ApplicationRecord
small: {
pixels: 160_000, # 400x400px
file_geometry_parser: FastGeometryParser,
blurhash: BLURHASH_OPTIONS,
},
}.freeze
@ -53,6 +60,8 @@ class MediaAttachment < ApplicationRecord
},
format: 'png',
time: 0,
file_geometry_parser: FastGeometryParser,
blurhash: BLURHASH_OPTIONS,
},
}.freeze
@ -166,11 +175,11 @@ class MediaAttachment < ApplicationRecord
def file_processors(f)
if f.file_content_type == 'image/gif'
[:gif_transcoder]
[:gif_transcoder, :blurhash_transcoder]
elsif VIDEO_MIME_TYPES.include? f.file_content_type
[:video_transcoder]
[:video_transcoder, :blurhash_transcoder]
else
[:lazy_thumbnail]
[:lazy_thumbnail, :blurhash_transcoder]
end
end
end

View File

@ -78,7 +78,7 @@ class User < ApplicationRecord
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: :email_changed?
validates_with BlacklistedEmailValidator, on: :create
validates_with EmailMxValidator, if: :validate_email_dns?
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
@ -107,13 +107,14 @@ class User < ApplicationRecord
:expand_spoilers, :default_language, :aggregate_reblogs, :show_application, :default_federation, to: :settings, prefix: :setting, allow_nil: false
attr_reader :invite_code
attr_writer :external
def confirmed?
confirmed_at.present?
end
def invited?
invite_id.present?
invite_id.present? && invite.valid_for_use?
end
def disable!
@ -273,13 +274,17 @@ class User < ApplicationRecord
private
def set_approved
self.approved = open_registrations? || invited?
self.approved = open_registrations? || invited? || external?
end
def open_registrations?
Setting.registrations_mode == 'open'
end
def external?
!!@external
end
def sanitize_languages
return if chosen_languages.nil?
chosen_languages.reject!(&:blank?)

View File

@ -2,7 +2,7 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer
context_extensions :atom_uri, :conversation, :sensitive,
:hashtag, :emoji, :focal_point
:hashtag, :emoji, :focal_point, :blurhash
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
class MediaAttachmentSerializer < ActivityPub::Serializer
include RoutingHelper
attributes :type, :media_type, :url, :name
attributes :type, :media_type, :url, :name, :blurhash
attribute :focal_point, if: :focal_point?
def type

View File

@ -14,6 +14,8 @@ class InitialStateSerializer < ActiveModel::Serializer
domain: Rails.configuration.x.local_domain,
admin: object.admin&.id&.to_s,
search_enabled: Chewy.enabled?,
repository: Mastodon::Version.repository,
source_url: Mastodon::Version.source_url,
version: Mastodon::Version.to_s,
invites_enabled: Setting.min_invite_role == 'user',
mascot: instance_presenter.mascot&.file&.url,

View File

@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
attributes :id, :type, :url, :preview_url,
:remote_url, :text_url, :meta,
:description
:description, :blurhash
def id
object.id.to_s

View File

@ -6,6 +6,7 @@ class BlockService < BaseService
UnfollowService.new.call(account, target_account) if account.following?(target_account)
UnfollowService.new.call(target_account, account) if target_account.following?(account)
RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
block = account.block!(target_account)

View File

@ -165,7 +165,7 @@ class FetchLinkCardService < BaseService
end
def meta_property(page, property)
page.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || page.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
page.at_xpath("//meta[contains(concat(' ', normalize-space(@property), ' '), ' #{property} ')]")&.attribute('content')&.value || page.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
end
def lock_options

View File

@ -2,7 +2,10 @@
class BlacklistedEmailValidator < ActiveModel::Validator
def validate(user)
return if user.invited?
@email = user.email
user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
end
@ -13,7 +16,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator
end
def on_blacklist?
return true if EmailDomainBlock.block?(@email)
return true if EmailDomainBlock.block?(@email)
return false if Rails.configuration.x.email_domains_blacklist.blank?
domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')

View File

@ -3,7 +3,7 @@
= image_tag (current_account&.user&.setting_auto_play_gif ? account.header_original_url : account.header_static_url), class: 'parallax'
.public-account-header__bar
= link_to short_account_url(account), class: 'avatar' do
= image_tag (current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url)
= image_tag (current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), id: 'profile_page_avatar', data: {original: full_asset_url(account.avatar_original_url), static: full_asset_url(account.avatar_static_url), autoplay: current_account&.user&.setting_auto_play_gif}
.public-account-header__tabs
.public-account-header__tabs__name
%h1

View File

@ -36,6 +36,6 @@
= f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path)
.actions
= f.button :button, sign_up_message, type: :submit
= f.button :button, @invite.present? ? t('auth.register') : sign_up_message, type: :submit
.form-footer= render 'auth/shared/links'

View File

@ -28,7 +28,7 @@
- elsif !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do

View File

@ -32,7 +32,7 @@
- elsif !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do

View File

@ -7,5 +7,7 @@ class ActivityPub::ProcessingWorker
def perform(account_id, body, delivered_to_account_id = nil)
ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.debug "Error processing incoming ActivityPub object: #{e}"
end
end

View File

@ -1,4 +1,6 @@
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, req|
ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish, _request_id, payload|
req = payload[:request]
next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
end

View File

@ -1,3 +1,4 @@
require 'stoplight'
Stoplight::Light.default_data_store = Stoplight::DataStore::Redis.new(Redis.current)
Stoplight::Light.default_notifiers = [Stoplight::Notifier::Logger.new(Rails.logger)]

View File

@ -2,8 +2,9 @@
pt-BR:
activerecord:
attributes:
status:
owned_poll: enquete
poll:
expires_at: Expira em
options: Escolhas
errors:
models:
account:

View File

@ -3,7 +3,7 @@ sk:
activerecord:
attributes:
poll:
expires_at: Uzávierka
expires_at: Trvá do
options: Voľby
status:
owned_poll: Anketa

View File

@ -68,6 +68,7 @@ ca:
admin: Administrador
bot: Bot
moderator: Moderador
unavailable: Perfil inaccessible
unfollow: Deixa de seguir
admin:
account_actions:
@ -80,6 +81,7 @@ ca:
destroyed_msg: Nota de moderació destruïda amb èxit!
accounts:
approve: Aprova
approve_all: Aprova'ls tots
are_you_sure: N'estàs segur?
avatar: Avatar
by_domain: Domini
@ -132,6 +134,7 @@ ca:
moderation_notes: Notes de moderació
most_recent_activity: Activitat més recent
most_recent_ip: IP més recent
no_account_selected: No s'han canviat els comptes perque no s'han seleccionat
no_limits_imposed: Sense límits imposats
not_subscribed: No subscrit
outbox_url: URL de la bústia de sortida
@ -144,6 +147,7 @@ ca:
push_subscription_expires: La subscripció PuSH expira
redownload: Actualitza el perfil
reject: Rebutja
reject_all: Rebutja'ls tots
remove_avatar: Eliminar avatar
remove_header: Treu la capçalera
resend_confirmation:
@ -330,6 +334,8 @@ ca:
expired: Caducat
title: Filtre
title: Convida
pending_accounts:
title: Comptes pendents (%{count})
relays:
add_new: Afegiu un nou relay
delete: Esborra
@ -854,18 +860,23 @@ ca:
revoke_success: S'ha revocat la sessió amb èxit
title: Sessions
settings:
account: Compte
account_settings: Ajustos del compte
appearance: Aparènça
authorized_apps: Aplicacions autoritzades
back: Torna a l'inici
back: Torna a Mastodon
delete: Eliminació del compte
development: Desenvolupament
edit_profile: Editar perfil
export: Exportar informació
export: Exportar dades
featured_tags: Etiquetes destacades
identity_proofs: Proves d'identitat
import: Importar
import_and_export: Importar i exportar
migrate: Migració del compte
notifications: Notificacions
preferences: Preferències
profile: Perfil
relationships: Seguits i seguidors
two_factor_authentication: Autenticació de dos factors
statuses:
@ -1040,7 +1051,7 @@ ca:
welcome:
edit_profile_action: Configurar perfil
edit_profile_step: Pots personalitzar el teu perfil penjant un avatar, un encapçalament, canviant el teu nom de visualització i molt més. Si prefereixes revisar els seguidors nous abans de que et puguin seguir, pots blocar el teu compte.
explanation: Aquests són alguns consells per començar
explanation: Aquests són alguns consells per a començar
final_action: Comença a publicar
final_step: 'Comença a publicar! Fins i tot sense seguidors, els altres poden veure els teus missatges públics, per exemple, a la línia de temps local i a les etiquetes ("hashtags"). És possible que vulguis presentar-te amb l''etiqueta #introductions.'
full_handle: El teu nom d'usuari sencer

View File

@ -269,6 +269,7 @@ co:
created_msg: U blucchime di u duminiu hè attivu
destroyed_msg: U blucchime di u duminiu ùn hè più attivu
domain: Duminiu
existing_domain_block_html: Avete digià impostu limite più strette nant'à %{name}, duvete <a href="%{unblock_url}">sbluccallu</a> primu.
new:
create: Creà un blucchime
hint: U blucchime di duminiu ùn impedirà micca a creazione di conti indè a database, mà metudi di muderazione specifiche saranu applicati.
@ -862,6 +863,7 @@ co:
settings:
account: Contu
account_settings: Parametri di u contu
appearance: Apparenza
authorized_apps: Applicazione auturizate
back: Ritornu nantà Mastodon
delete: Suppressione di u contu

View File

@ -273,6 +273,7 @@ cs:
created_msg: Blokace domény se právě vyřizuje
destroyed_msg: Blokace domény byla zrušena
domain: Doména
existing_domain_block_html: Pro účet %{name} jste již nastavil/a přísnější omezení, musíte jej nejdříve <a href="%{unblock_url}">odblokovat</a>.
new:
create: Vytvořit blokaci
hint: Blokace domény nezakáže vytváření záznamů účtů v databázi, ale bude na tyto účty zpětně a automaticky aplikovat specifické metody moderování.

View File

@ -3,7 +3,7 @@ sk:
devise:
confirmations:
confirmed: Tvoja emailová adresa bola úspešne overená.
send_instructions: O niekoľko minút obdržíš email s inštrukciami ako potvrdiť svoj účet. Prosím, skontroluj si aj zložku spam, ak sa k tebe toto potvrdenie nedostalo.
send_instructions: O niekoľko minút obdržíš email s pokynmi ako potvrdiť svoj účet. Prosím, skontroluj si aj zložku spam, ak sa k tebe toto potvrdenie nedostalo.
send_paranoid_instructions: Ak sa tvoja emailová adresa nachádza v našej databázi, o niekoľko minút obdržíš email s pokynmi ako potvrdiť svoj účet. Prosím, skontroluj aj zložku spam, ak sa k tebe toto potvrdenie nedostalo.
failure:
already_authenticated: Už si prihlásený/á.
@ -11,27 +11,27 @@ sk:
invalid: Nesprávny %{authentication_keys}, alebo heslo.
last_attempt: Máš posledný pokus pred zamknutím tvojho účtu.
locked: Tvoj účet je zamknutý.
not_found_in_database: Nesprávny %{authentication_keys} alebo heslo.
not_found_in_database: Nesprávny %{authentication_keys}, alebo heslo.
pending: Tvoj účet je stále prehodnocovaný.
timeout: Vaša aktívna sezóna vypršala. Pre pokračovanie sa prosím znovu prihláste.
timeout: Tvoja aktívna sezóna vypršala. Pre pokračovanie sa prosím prihlás znovu.
unauthenticated: K pokračovaniu sa musíš zaregistrovať alebo prihlásiť.
unconfirmed: Pred pokračovaním musíš potvrdiť svoj email.
mailer:
confirmation_instructions:
action: Potvŕď emailovú adresu
action: Potvrď emailovú adresu
action_with_app: Potvrď a vráť sa na %{app}
explanation: S touto emailovou adresou si si vytvoril/a účet na %{host}. Si iba jeden klik od jeho aktivácie. Pokiaľ si to ale nebol/a ty, prosím ignoruj tento email.
extra_html: Prosím pozri sa aj na <a href="%{terms_path}"> pravidlá tohto servera,</a> a <a href="%{policy_path}"> naše užívaťeľské podiemky</a>.
subject: 'Mastodon: Potvrdzovacie inštrukcie pre %{instance}'
extra_html: Prosím, pozri sa aj na <a href="%{terms_path}"> pravidlá tohto servera,</a> a <a href="%{policy_path}"> naše užívaťeľské podiemky</a>.
subject: 'Mastodon: Potvrdzovacie pokyny pre %{instance}'
title: Potvrď emailovú adresu
email_changed:
explanation: 'Emailová adresa tvojho účtu bude zmenená na:'
extra: Pokiaľ si nezmenil/a svoj email, je pravdepodobné že niekto iný získal prístup k tvojmu účtu. Naliehavo preto prosím zmeň svoje heslo, alebo kontaktuj administrátora tohto serveru pokiaľ si vymknutý/á zo svojho účtu.
extra: Ak si nezmenil/a svoj email, je pravdepodobné, že niekto iný získal prístup k tvojmu účtu. Naliehavo preto prosím zmeň svoje heslo, alebo kontaktuj administrátora tohto serveru, pokiaľ si vymknutý/á zo svojho účtu.
subject: 'Mastodon: Emailová adresa bola zmenená'
title: Nová emailová adresa
password_change:
explanation: Heslo k tvojmu účtu bolo zmenené.
extra: Ak si heslo nezmenil/a, je pravdepodobné že niekto iný získal prístup k tvojmu účtu. Naliehavo preto prosím zmeň svoje heslo, alebo kontaktuj administrátora tohto serveru pokiaľ si vymknutý/á zo svojho účtu.
extra: Ak si heslo nezmenil/a, je pravdepodobné, že niekto iný získal prístup k tvojmu účtu. Naliehavo preto prosím zmeň svoje heslo, alebo kontaktuj administrátora tohto serveru, pokiaľ si vymknutý/á zo svojho účtu.
subject: 'Mastodon: Heslo bolo zmenené'
title: Heslo bolo zmenené
reconfirmation_instructions:
@ -42,17 +42,17 @@ sk:
reset_password_instructions:
action: Zmeň svoje heslo
explanation: Vyžiadal/a si si nové heslo pre svoj účet.
extra: Pokiaľ si túto akciu nevyžiadal/a, prosím ignoruj tento email. Tvoje heslo nebude zmenené pokiaľ nepostúpiš na adresu uvedenú vyššie a vytvoríš si nové.
subject: 'Mastodon: Inštrukcie pre obnovu hesla'
extra: Ak si túto akciu nevyžiadal/a, prosím ignoruj tento email. Tvoje heslo nebude zmenené pokiaľ nepostúpiš na adresu uvedenú vyššie a vytvoríš si nové.
subject: 'Mastodon: Pokyny pre obnovu hesla'
title: Nastav nové heslo
unlock_instructions:
subject: 'Mastodon: Inštrukcie pre odomknutie účtu'
subject: 'Mastodon: Pokyny na odomknutie účtu'
omniauth_callbacks:
failure: Nebolo možné ťa overiť z dôvodu,%{kind} že "%{reason}".
failure: Nebolo možné ťa overiť z %{kind}, lebo "%{reason}".
success: Úspešné overenie z účtu %{kind}.
passwords:
no_token: Túto stránku nemôžete navštíviť pokiaľ neprichádzate z emailu s inštrukciami na obnovu hesla. Pokiaľ prichádzate z tohto emailu, prosím uistite sa že ste použili celú URL z emailu.
send_instructions: Pokiaľ sa tvoja emailová adresa nachádza v databázi, tak o niekoľko minút obdržíš email s inštrukciami ako nastaviť nové heslo. Ak máš pocit, že si email neobdržal/a, prosím skontroluj aj svoju spam zložku.
no_token: Túto stránku nemôžeš navštíviť, ak neprichádzaš z emailu s pokynmi na obnovu hesla. Pokiaľ prichádzaš z tohto emailu, prosím uisti sa že si použil/a celú URL adresu z emailu.
send_instructions: Ak sa tvoja emailová adresa nachádza v databázi, tak o niekoľko minút obdržíš email s pokynmi ako nastaviť nové heslo. Ak máš pocit, že si email neobdržal/a, prosím skontroluj aj svoju spam zložku.
send_paranoid_instructions: Ak sa tvoja emailová adresa nachádza v databázi, za chvíľu obdržíš odkaz pre obnovu hesla na svoj email. Skontroluj ale prosím aj svoj spam, ak tento email nevidíš.
updated: Tvoje heslo bolo úspešne zmenené. Teraz si prihlásený/á.
updated_not_active: Tvoje heslo bolo úspešne zmenené.
@ -66,9 +66,9 @@ sk:
update_needs_confirmation: Účet bol úspešne pozmenený, ale ešte potrebujeme overiť tvoju novú emailovú adresu. Pre overenie prosím klikni na link v správe ktorú si dostal/a na email. Takisto ale skontroluj aj svoju spam zložku, ak sa ti zdá, že si tento email nedostal/a.
updated: Tvoj účet bol úspešne aktualizovaný.
sessions:
already_signed_out: Odhlásil/a si sa úspešné.
signed_in: Prihlásil/a si sa úspešné.
signed_out: Odhlásil/a si sa úspešné.
already_signed_out: Už si sa úspešne odhlásil/a.
signed_in: Prihlásil/a si sa úspešne.
signed_out: Odhlásil/a si sa úspešne.
unlocks:
send_instructions: O niekoľko minút obdržíš email s pokynmi, ako nastaviť nové heslo. Prosím, skontroluj ale aj svoju spam zložku, pokiaľ sa ti zdá, že si tento email nedostal/a.
send_paranoid_instructions: Ak tvoj účet existuje, o niekoľko minút obdržíš email s pokynmi ako si ho odomknúť. Prosím, skontroluj ale aj svoju spam zložku, pokiaľ sa ti zdá, že si tento email nedostal/a.
@ -79,8 +79,8 @@ sk:
confirmation_period_expired: musí byť potvrdený do %{period}, prosím požiadaj o nový
expired: vypŕšal, prosím, vyžiadaj si nový
not_found: nenájdený
not_locked: nebol uzamknutý
not_locked: nebol zamknutý
not_saved:
few: "%{resource} nebol uložený kôli %{count} chybám:"
one: "%{resource} nebol uložený kôli chybe:"
other: "%{resource} nebol uložený kôli %{count} chybám:"
few: "%{resource} nebol uložený kvôli %{count} chybám:"
one: "%{resource} nebol uložený kvôli chybe:"
other: "%{resource} nebol uložený kvôli %{count} chybám:"

View File

@ -269,6 +269,7 @@ en:
created_msg: Domain block is now being processed
destroyed_msg: Domain block has been undone
domain: Domain
existing_domain_block_html: You have already imposed stricter limits on %{name}, you need to <a href="%{unblock_url}">unblock it</a> first.
new:
create: Create block
hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.

View File

@ -260,10 +260,10 @@ fr:
title: Nouveau blocage de domaine
reject_media: Fichiers média rejetés
reject_media_hint: Supprime localement les fichiers média stockés et refuse den télécharger ultérieurement. Ne concerne pas les suspensions
reject_reports: Rapports de rejet
reject_reports_hint: Ignorez tous les rapports provenant de ce domaine. Sans objet pour les suspensions
reject_reports: Rejeter les signalements
reject_reports_hint: Ignorez tous les signalements provenant de ce domaine. Ne concerne pas les suspensions
rejecting_media: rejet des fichiers multimédia
rejecting_reports: rejet de rapports
rejecting_reports: rejet des signalements
severity:
silence: silencié
suspend: suspendu

View File

@ -68,6 +68,7 @@ nl:
admin: Beheerder
bot: Bot
moderator: Moderator
unavailable: Profiel niet beschikbaar
unfollow: Ontvolgen
admin:
account_actions:
@ -80,6 +81,7 @@ nl:
destroyed_msg: Verwijderen van opmerking voor moderatoren geslaagd!
accounts:
approve: Goedkeuren
approve_all: Alles goedkeuren
are_you_sure: Weet je het zeker?
avatar: Avatar
by_domain: Domein
@ -132,6 +134,7 @@ nl:
moderation_notes: Opmerkingen voor moderatoren
most_recent_activity: Laatst actief
most_recent_ip: Laatst gebruikt IP-adres
no_account_selected: Er zijn geen accounts veranderd, omdat er geen een was geselecteerd
no_limits_imposed: Geen limieten ingesteld
not_subscribed: Niet geabonneerd
outbox_url: Outbox-URL
@ -144,6 +147,7 @@ nl:
push_subscription_expires: PuSH-abonnement verloopt op
redownload: Profiel vernieuwen
reject: Afkeuren
reject_all: Alles afkeuren
remove_avatar: Avatar verwijderen
remove_header: Omslagfoto verwijderen
resend_confirmation:
@ -245,6 +249,7 @@ nl:
feature_profile_directory: Gebruikersgids
feature_registrations: Registraties
feature_relay: Federatierelay
feature_timeline_preview: Voorvertoning van tijdlijn
features: Functies
hidden_service: Federatie met verborgen diensten
open_reports: onopgeloste rapportages
@ -329,6 +334,8 @@ nl:
expired: Verlopen
title: Filter
title: Uitnodigingen
pending_accounts:
title: Accounts in afwachting (%{count})
relays:
add_new: Nieuwe relayserver toevoegen
delete: Verwijderen
@ -428,14 +435,14 @@ nl:
desc_html: Medewerkersbadge op profielpagina tonen
title: Medewerkersbadge tonen
site_description:
desc_html: Dit wordt als een alinea op de voorpagina getoond. Beschrijf wat er speciaal is aan deze server en andere zaken die van belang zijn. Je kan HTML gebruiken, zoals <code>&lt;a&gt;</code> en <code>&lt;em&gt;</code>.
title: Omschrijving Mastodonserver
desc_html: Introductie-alinea voor de API. Beschrijf wat er speciaal is aan deze server en andere zaken die van belang zijn. Je kan HTML gebruiken, zoals <code>&lt;a&gt;</code> en <code>&lt;em&gt;</code>.
title: Omschrijving Mastodonserver (API)
site_description_extended:
desc_html: Een goede plek voor je gedragscode, regels, richtlijnen en andere zaken die jouw server uniek maken. Je kan ook hier HTML gebruiken
title: Uitgebreide omschrijving Mastodonserver
site_short_description:
desc_html: Dit wordt in de zijbalk getoond als en als metatag in de paginabron. Beschrijf in één alinea wat Mastodon is en wat deze server speciaal maakt. De (langere) omschrijving van de Mastodonserver wordt gebruikt wanneer dit veld wordt leeg gelaten.
title: Korte omschrijving Mastodonserver
desc_html: Dit wordt gebruikt op de voorpagina, in de zijbalk op profielpagina's en als metatag in de paginabron. Beschrijf in één alinea wat Mastodon is en wat deze server speciaal maakt.
title: Omschrijving Mastodonserver (website)
site_terms:
desc_html: Je kan hier jouw eigen privacybeleid, gebruiksvoorwaarden en ander juridisch jargon kwijt. Je kan HTML gebruiken
title: Aangepaste gebruiksvoorwaarden
@ -585,6 +592,9 @@ nl:
content: Het spijt ons, er is aan onze kant iets fout gegaan.
title: Er is iets mis
noscript_html: Schakel JavaScript in om de webapp van Mastodon te kunnen gebruiken. Als alternatief kan je een <a href="%{apps_path}">Mastodon-app</a> zoeken voor jouw platform.
existing_username_validator:
not_found: Kon geen lokale gebruiker met die gebruikersnaam vinden
not_found_multiple: Kon %{usernames} niet vinden
exports:
archive_takeout:
date: Datum
@ -628,10 +638,13 @@ nl:
all: Alles
changes_saved_msg: Wijzigingen succesvol opgeslagen!
copy: Kopiëren
order_by: Sorteer op
save_changes: Wijzigingen opslaan
validation_errors:
one: Er is iets niet helemaal goed! Bekijk onderstaande fout
other: Er is iets niet helemaal goed! Bekijk onderstaande %{count} fouten
html_validator:
invalid_markup: 'bevat ongeldige HTML-opmaak: %{error}'
identity_proofs:
active: Actief
authorize: Ja, autoriseren
@ -646,6 +659,8 @@ nl:
i_am_html: Ik ben %{username} op %{service}.
identity: Identiteit
inactive: Inactief
publicize_checkbox: 'En toot dit:'
publicize_toot: 'Het is bewezen! Ik ben %{username} op %{service}: %{url}'
status: Verificatiestatus
view_proof: Bekijk bewijs
imports:
@ -768,6 +783,8 @@ nl:
relationships:
activity: Accountactiviteit
dormant: Sluimerend
last_active: Laatst actief
most_recent: Recentelijk gevolgd
moved: Verhuisd
mutual: Wederzijds
primary: Primair
@ -843,6 +860,9 @@ nl:
revoke_success: Sessie succesvol ingetrokken
title: Sessies
settings:
account: Account
account_settings: Accountinstellingen
appearance: Uiterlijk
authorized_apps: Geautoriseerde apps
back: Terug naar Mastodon
delete: Account verwijderen
@ -852,9 +872,11 @@ nl:
featured_tags: Uitgelichte hashtags
identity_proofs: Identiteitsbewijzen
import: Importeren
import_and_export: Importeren en exporteren
migrate: Accountmigratie
notifications: Meldingen
preferences: Voorkeuren
profile: Profiel
relationships: Volgers en gevolgden
two_factor_authentication: Tweestapsverificatie
statuses:

View File

@ -68,6 +68,7 @@ pt-BR:
admin: Administrador
bot: Robô
moderator: Moderador
unavailable: Perfil indisponível
unfollow: Deixar de seguir
admin:
account_actions:
@ -80,6 +81,7 @@ pt-BR:
destroyed_msg: Nota de moderação excluída com sucesso!
accounts:
approve: Aprovar
approve_all: Aprovar tudo
are_you_sure: Você tem certeza?
avatar: Avatar
by_domain: Domínio
@ -132,6 +134,7 @@ pt-BR:
moderation_notes: Notas de moderação
most_recent_activity: Atividade mais recente
most_recent_ip: IP mais recente
no_account_selected: Nenhuma conta foi modificada, pois nenhuma conta foi selecionada
no_limits_imposed: Nenhum limite imposto
not_subscribed: Não está inscrito
outbox_url: URL da caixa de saída
@ -144,6 +147,7 @@ pt-BR:
push_subscription_expires: Inscrição PuSH expira
redownload: Atualizar perfil
reject: Rejeitar
reject_all: Rejeitar tudo
remove_avatar: Remover avatar
remove_header: Remover cabeçalho
resend_confirmation:
@ -330,6 +334,8 @@ pt-BR:
expired: Expirados
title: Filtro
title: Convites
pending_accounts:
title: Contas pendentes (%{count})
relays:
add_new: Adicionar novo repetidor
delete: Excluir
@ -854,6 +860,9 @@ pt-BR:
revoke_success: Sessão revogada com sucesso
title: Sessões
settings:
account: Conta
account_settings: Configurações da conta
appearance: Aparência
authorized_apps: Apps autorizados
back: Voltar para o Mastodon
delete: Exclusão de conta
@ -863,9 +872,11 @@ pt-BR:
featured_tags: Hashtags em destaque
identity_proofs: Provas de identidade
import: Importar
import_and_export: Importar e exportar
migrate: Migração de conta
notifications: Notificações
preferences: Preferências
profile: Perfil
relationships: Seguindo e seguidores
two_factor_authentication: Autenticação em dois passos
statuses:

View File

@ -41,6 +41,8 @@ nl:
name: 'Je wilt misschien een van deze gebruiken:'
imports:
data: CSV-bestand dat op een andere Mastodonserver werd geëxporteerd
invite_request:
text: Dit helpt ons om jouw aanvraag te beoordelen
sessions:
otp: 'Voer de tweestaps-aanmeldcode vanaf jouw mobiele telefoon in of gebruik een van jouw herstelcodes:'
user:
@ -118,12 +120,15 @@ nl:
must_be_follower: Meldingen van mensen die jou niet volgen blokkeren
must_be_following: Meldingen van mensen die jij niet volgt blokkeren
must_be_following_dm: Directe berichten van mensen die jij niet volgt blokkeren
invite_request:
text: Waarom wil jij je aanmelden?
notification_emails:
digest: Periodiek e-mails met een samenvatting versturen
favourite: Een e-mail versturen wanneer iemand jouw toot aan hun favorieten heeft toegevoegd
follow: Een e-mail versturen wanneer iemand jou volgt
follow_request: Een e-mail versturen wanneer iemand jou wil volgen
mention: Een e-mail versturen wanneer iemand jou vermeld
pending_account: Een e-mail verzenden wanneer een nieuw account moet worden beoordeeld
reblog: Een e-mail versturen wanneer iemand jouw toot heeft geboost
report: Verstuur een e-mail wanneer een nieuw rapportage is ingediend
'no': Nee

View File

@ -29,8 +29,8 @@ pl:
setting_aggregate_reblogs: Nie pokazuj nowych podbić dla wpisów, które zostały niedawno podbite (dotyczy tylko nowo otrzymanych podbić)
setting_default_language: Język Twoich wpisów może być wykrywany automatycznie, ale nie zawsze jest to dokładne
setting_display_media_default: Ukrywaj zawartość oznaczoną jako wrażliwa
setting_display_media_hide_all: Zawsze ukrywaj zawartość multimedialną
setting_display_media_show_all: Zawsze pokazuj zawartość multimedialną jako wrażliwą
setting_display_media_hide_all: Zawsze oznaczaj zawartość multimedialną jako wrażliwą
setting_display_media_show_all: Nie ukrywaj zawartości multimedialnej oznaczonej jako wrażliwa
setting_hide_network: Informacje o tym, kto Cię śledzi i kogo śledzisz nie będą widoczne
setting_noindex: Wpływa na widoczność strony profilu i Twoich wpisów
setting_show_application: W informacjach o wpisie będzie widoczna informacja o aplikacji, z której został wysłany

View File

@ -41,6 +41,8 @@ pt-BR:
name: 'Você pode querer usar um destes:'
imports:
data: Arquivo CSV exportado de outra instância do Mastodon
invite_request:
text: Isso vai nos ajudar a revisar sua aplicação
sessions:
otp: 'Insira o código de autenticação gerado pelo app no seu celular ou use um dos códigos de recuperação:'
user:
@ -119,12 +121,15 @@ pt-BR:
must_be_follower: Bloquear notificações de não-seguidores
must_be_following: Bloquear notificações de pessoas que você não segue
must_be_following_dm: Bloquear mensagens diretas de pessoas que você não segue
invite_request:
text: Por que você quer se cadastrar?
notification_emails:
digest: Mandar e-mails com relatórios
favourite: Mandar um e-mail quando alguém favoritar suas postagens
follow: Mandar um e-mail quando alguém te seguir
follow_request: Mandar um e-maill quando alguém solicitar ser seu seguidor
mention: Mandar um e-mail quando alguém te mencionar
pending_account: Mandar um -mail quando uma nova conta precisar ser revisada
reblog: Mandar um e-mail quando alguém compartilhar suas postagens
report: Mandar um e-mail quando uma nova denúncia é submetida
'no': Não

View File

@ -10,7 +10,7 @@ sk:
api: API
apps: Aplikácie
apps_platforms: Uživaj Mastodon z iOSu, Androidu a iných platforiem
browse_directory: Prehľadávaj databázu profilov a filtruj ju podľa záujmov
browse_directory: Prehľadávaj databázu profilov, filtruj podľa záujmov
browse_public_posts: Prebádaj naživo prúd verejných príspevkov na Mastodone
contact: Kontakt
contact_missing: Nezadaný
@ -20,9 +20,9 @@ sk:
extended_description_html: |
<h3>Pravidlá</h3>
<p>Žiadne zatiaľ uvedené nie sú</p>
federation_hint_html: S účtom na %{instance} budeš môcť následovať ľúdí na hociakom inom Mastodon serveri, ale aj inde.
federation_hint_html: S účtom na %{instance} budeš môcť následovať ľúdí na hociakom Mastodon serveri, ale aj inde.
generic_description: "%{domain} je jeden server v sieti"
get_apps: Vyskúšaj mobilnú aplikáciu
get_apps: Vyskúšaj aplikácie
hosted_on: Mastodon hostovaný na %{domain}
learn_more: Zisti viac
privacy_policy: Ustanovenia o súkromí
@ -32,22 +32,22 @@ sk:
status_count_after:
few: príspevkov
one: príspevok
other: príspevkov
other: príspevky
status_count_before: Ktorí napísali
tagline: Následuj kamarátov, a objavuj nových
terms: Podmienky užívania
user_count_after:
few: užívatelia
few: užívateľov
one: užívateľ
other: užívateľov
other: užívatelia
user_count_before: Domov pre
what_is_mastodon: Čo je Mastodon?
accounts:
choices_html: "%{name}vé voľby:"
follow: Sleduj
follow: Následuj
followers:
few: Sledovatelia
one: Sledujúci
few: Sledovateľov
one: Sledovateľ
other: Sledovatelia
following: Sledovaní
joined: Pridal/a sa v %{date}
@ -70,8 +70,9 @@ sk:
reserved_username: Prihlasovacie meno je rezervované
roles:
admin: Administrátor
bot: Automat
bot: Bot
moderator: Moderátor
unavailable: Profil nieje dostupný
unfollow: Prestaň sledovať
admin:
account_actions:
@ -84,12 +85,13 @@ sk:
destroyed_msg: Moderátorska poznámka bola úspešne zmazaná!
accounts:
approve: Schváľ
approve_all: Schváľ všetky
are_you_sure: Si si istý/á?
avatar: Maskot
by_domain: Doména
change_email:
changed_msg: Email k tomuto účtu bol úspešne zmenený!
current_email: Súčastný email
changed_msg: Email pre tento účet bol úspešne zmenený!
current_email: Súčasný email
label: Zmeň email
new_email: Nový email
submit: Zmeň email
@ -97,37 +99,37 @@ sk:
confirm: Potvrď
confirmed: Potvrdený
confirming: Potvrdzujúci
deleted: Zmazané
demote: Degradovať
deleted: Vymazané
demote: Degraduj
disable: Zablokuj
disable_two_factor_authentication: Zakáž 2FA
disabled: Blokovaný
display_name: Zobraziť meno
display_name: Ukáž meno
domain: Doména
edit: Uprav
email: Email
email_status: Stav emailu
enable: Povoliť
enable: Povoľ
enabled: Povolený
feed_url: URL časovej osi
feed_url: URL adresa časovej osi
followers: Sledujúci
followers_url: URL sledujúcich
followers_url: URL adresa sledujúcich
follows: Sledovania
header: Hlavička
inbox_url: URL prijatých správ
inbox_url: URL adresa prijatých správ
invited_by: Pozvaný/á užívateľom
ip: IP
ip: IP adresa
joined: Pridal/a sa
location:
all: Všetko
local: Miestne
remote: Federované
title: Lokácia
title: Umiestnenie
login_status: Stav prihlásenia
media_attachments: Prílohy
memorialize: Zmeniť na "Navždy budeme spomínať"
memorialize: Zmeň na "Navždy budeme spomínať"
moderation:
active: Aktívny
active: Aktívny/a
all: Všetko
pending: Čakajúci
silenced: Umlčané
@ -135,21 +137,22 @@ sk:
title: Moderácia
moderation_notes: Moderátorské poznámky
most_recent_activity: Posledná aktivita
most_recent_ip: Posledná IP
most_recent_ip: Posledná IP adresa
no_limits_imposed: Nie sú stanovené žiadné obmedzenia
not_subscribed: Neodoberá
outbox_url: URL poslaných
pending: Vyžaduje posúdenie
perform_full_suspension: Vylúč
profile_url: URL profilu
promote: Povýš
profile_url: URL adresa profilu
promote: Vyzdvihni
protocol: Protokol
public: Verejná os
public: Verejná časová os
push_subscription_expires: PuSH odoberanie expiruje
redownload: Obnov profil
reject: Odmietni
remove_avatar: Odstrániť avatár
remove_header: Odstráň hlavičku
reject: Zamietni
reject_all: Zamietni všetky
remove_avatar: Vymaž avatar
remove_header: Vymaž hlavičku
resend_confirmation:
already_confirmed: Tento užívateľ je už potvrdený
send: Odošli potvrdzovací email znovu
@ -164,7 +167,7 @@ sk:
staff: Člen
user: Užívateľ
salmon_url: Salmon adresa
search: Hľadať
search: Hľadaj
shared_inbox_url: URL zdieľanej schránky
show:
created_reports: Vytvorené hlásenia
@ -172,15 +175,15 @@ sk:
silence: Stíš
silenced: Utíšený/é
statuses: Príspevky
subscribe: Odoberať
subscribe: Odoberaj
suspended: Zablokovaní
title: Účty
unconfirmed_email: Nepotvrdený email
undo_silenced: Zruš stíšenie
undo_silenced: Zruš stíšenie
undo_suspension: Zruš blokovanie
unsubscribe: Prestaň odoberať
username: Prezývka
warn: Varovať
warn: Varuj
web: Web
action_logs:
actions:
@ -227,7 +230,7 @@ sk:
disable: Zakázať
disabled_msg: Emoji bolo úspešne zakázané
emoji: Emotikony
enable: Povoliť
enable: Povoľ
enabled_msg: Emoji bolo úspešne povolené
image_hint: PNG do 50KB
listed: V zozname
@ -240,7 +243,7 @@ sk:
unlisted: Nie je na zozname
update_failed_msg: Nebolo možné aktualizovať toto emoji
updated_msg: Emoji bolo úspešne aktualizované!
upload: Nahrať
upload: Nahraj
dashboard:
backlog: odložené aktivity
config: Nastavenia
@ -249,10 +252,11 @@ sk:
feature_profile_directory: Katalóg profilov
feature_registrations: Registrácie
feature_relay: Federovací mostík
feature_timeline_preview: Náhľad časovej osi
features: Vymoženosti
hidden_service: Federácia so skrytými službami
open_reports: otvorené hlásenia
recent_users: Nedávny užívatelia
recent_users: Nedávni užívatelia
search: Celofrázové vyhľadávanie
single_user_mode: Jednouživateľské rozhranie
software: Softvér
@ -264,10 +268,11 @@ sk:
week_users_active: aktívni tento týždeň
week_users_new: užívateľov počas tohto týždňa
domain_blocks:
add_new: Pridaj nové doménové blokovanie
created_msg: Doména je v procese blokovania
add_new: Blokuj novú doménu
created_msg: Doména je v štádiu blokovania
destroyed_msg: Blokovanie domény bolo zrušené
domain: Doména
existing_domain_block_html: Pre účet %{name} si už nahodil/a přísnejšie obmedzenie, najskôr ho teda musíš <a href="%{unblock_url}">odblokovať</a>.
new:
create: Vytvor blokovanie domény
hint: Blokovanie domény stále dovolí vytvárať nové účty v databázi, ale tieto budú spätne automaticky moderované.
@ -284,7 +289,7 @@ sk:
rejecting_media: odmietanie médiálnych súborov
rejecting_reports: odmietané hlásenia
severity:
silence: utíšené
silence: stíšený
suspend: vylúčený
show:
affected_accounts:
@ -295,12 +300,12 @@ sk:
silence: Zruš stíšenie všetkých existujúcich účtov z tejto domény
suspend: Zruš suspendáciu všetkých existujúcich účtov z tejto domény
title: Zruš blokovanie domény %{domain}
undo: Vrátiť späť
undo: Vráť späť
undo: Odvolaj blokovanie domény
email_domain_blocks:
add_new: Pridaj nový
created_msg: Emailová doména bola úspešne pridaná do zoznamu zakázaných
delete: Zmazať
delete: Vymaž
destroyed_msg: Emailová doména bola úspešne vymazaná zo zoznamu zakázaných
domain: Doména
new:
@ -328,15 +333,15 @@ sk:
total_reported: Nahlásenia o nich
total_storage: Mediálne prílohy
invites:
deactivate_all: Pozastav všetky
deactivate_all: Pozastav všetky
filter:
all: Všetky
available: Dostupné
expired: Vypršalo
title: Filtrovať
title: Filtruj
title: Pozvánky
relays:
add_new: Pridaj novú priechodnú oporu
add_new: Pridaj nový federovací mostík
delete: Vymaž
description_html: "<strong>Federovací mostík</strong> je prechodný server ktorý obmieňa veľké množstvá verejných príspevkov medzi tými servermi ktoré na od neho odoberajú, aj doňho prispievajú. <strong>Môže to pomôcť malým a stredným instanciám objavovať federovaný obsah</strong>, čo inak vyžaduje aby miestni užívatelia ručne následovali iných ľudí zo vzdialených instancií."
disable: Pozastav
@ -344,7 +349,7 @@ sk:
enable: Povoľ
enable_hint: Ak povolíš, tvoj server bude odoberať všetky verejné príspevky z tohto mostu, a začne posielať verejné príspevky tvojho servera na tento most.
enabled: Povolené
inbox_url: URL mostu
inbox_url: URL adresa mostu
pending: Čakám na povolenie od prechodného mostu
save_and_enable: Uložiť a povoliť
setup: Nastav prepojenie s mostom
@ -359,7 +364,7 @@ sk:
report: nahlás
action_taken_by: Zákrok vykonal/a
are_you_sure: Si si istý/á?
assign_to_self: Priraď k sebe
assign_to_self: Priraď sebe
assigned: Priradený moderátor
comment:
none: Žiadne
@ -379,7 +384,7 @@ sk:
resolved: Vyriešené
resolved_msg: Hlásenie úspešne vyriešené!
status: Stav
title: Reporty
title: Hlásenia
unassign: Odobrať
unresolved: Nevyriešené
updated_at: Aktualizované
@ -391,7 +396,7 @@ sk:
desc_html: Ak je prezývok viacero, každú oddeľte čiarkou. Možno zadať iba miestne, odomknuté účty. Pokiaľ necháte prázdne, je to pre všetkých miestnych administrátorov.
title: Štandardní následovníci nových užívateľov
contact_information:
email: Pracovný e-mail
email: Pracovný email
username: Kontaktné užívateľské meno
custom_css:
desc_html: Uprav vzhľad pomocou CSS, ktoré je načítané na každej stránke
@ -508,11 +513,13 @@ sk:
invalid_url: Zadaná URL adresa je nesprávna
regenerate_token: Znovu vygenerovať prístupový token
token_regenerated: Prístupový token bol úspešne vygenerovaný znova
warning: Na tieto údaje dávajte ohromný pozor. Nikdy ich s nikým nezďieľajte!
your_token: Váš prístupový token
warning: Na tieto údaje dávaj ohromný pozor. Nikdy ich s nikým nezďieľaj!
your_token: Tvoj prístupový token
auth:
apply_for_account: Vyžiadaj si pozvánku
change_password: Heslo
confirm_email: Potvrdiť email
checkbox_agreement_html: Súhlasím s <a href="%{rules_path}" target="_blank">pravidlami servera</a>, aj s <a href="%{terms_path}" target="_blank">prevoznými podmienkami</a>
confirm_email: Potvrď email
delete_account: Vymaž účet
delete_account_html: Pokiaľ chceš svoj účet odtiaľto vymazať, môžeš tak <a href="%{path}">urobiť tu</a>. Budeš požiadaný/á o potvrdenie tohto kroku.
didnt_get_confirmation: Neobdržal/a si kroky na potvrdenie?
@ -521,16 +528,17 @@ sk:
login: Prihlás sa
logout: Odhlás sa
migrate_account: Presúvam sa na iný účet
migrate_account_html: Pokiaľ si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
or_log_in_with: Alebo prihlásiť z
migrate_account_html: Ak si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
or_log_in_with: Alebo prihlás s
providers:
cas: CAS
saml: SAML
register: Zaregistruj sa
resend_confirmation: Poslať potvrdzujúce pokyny znovu
reset_password: Resetovať heslo
resend_confirmation: Zašli potvrdzujúce pokyny znovu
reset_password: Obnov heslo
security: Zabezpečenie
set_new_password: Nastaviť nové heslo
set_new_password: Nastav nové heslo
trouble_logging_in: Problém s prihlásením?
authorize_follow:
already_following: Tento účet už následuješ
error: Naneštastie nastala chyba pri hľadaní vzdialeného účtu
@ -636,10 +644,10 @@ sk:
other: Niečo ešte stále nieje v poriadku! Prosím skontroluj všetky %{count} nižšie uvedené pochybenia
imports:
modes:
merge: Spoj dohromady
merge: Spoj dohromady
merge_long: Ponechaj existujúce záznamy a pridaj k nim nové
overwrite: Prepíš
overwrite_long: Nahraď súčasné záznamy s novými
overwrite_long: Nahraď súčasné záznamy novými
preface: Môžeš nahrať dáta ktoré si exportoval/a z iného Mastodon serveru, ako sú napríklad zoznamy ľudí ktorých sleduješ, alebo blokuješ.
success: Tvoje dáta boli nahraté úspešne, a teraz budú spracované v danom čase
types:
@ -647,10 +655,10 @@ sk:
domain_blocking: Zoznam blokovaných domén
following: Zoznam sledovaných
muting: Zoznam ignorovaných
upload: Nahrať
upload: Nahraj
in_memoriam_html: V pamäti.
invites:
delete: Deaktivovať
delete: Deaktivuj
expired: Neplatné
expires_in:
'1800': 30 minút
@ -661,13 +669,13 @@ sk:
'86400': 1 deň
expires_in_prompt: Nikdy
generate: Vygeneruj
invited_by: 'Bol/a si pozvan/á užívateľom:'
invited_by: 'Bol/a si pozvaný/á užívateľom:'
max_uses:
few: "%{count} použitia"
one: jedno použitie
other: "%{count} použití"
max_uses_prompt: Bez limitov
prompt: Vygeneruj a zdieľaj linky s ostatnými aby mali umožnený prístup k tomuto serveru
max_uses_prompt: Bez obmedzení
prompt: Vygeneruj a zdieľaj linky s ostatnými, aby mali umožnený prístup k tomuto serveru
table:
expires_at: Vyprší
uses: Používa
@ -692,16 +700,16 @@ sk:
body: Tu nájdete krátky súhrn správ ktoré ste zmeškali od svojej poslednj návštevi od %{since}
mention: "%{name} ťa spomenul/a v:"
new_followers_summary:
few: Taktiež, získal/a si %{count} nových následovníkov za tú dobu čo si bol/a preč. Yay!
one: Taktiež, získal/a si jedného nového následovníka zatiaľ čo si bol/a preč. Yay!
other: Taktiež, získal/a si %{count} nových následovníkov za tú dobu čo si bol/a preč. Yay!
few: Tiež si získal/a %{count} nových následovateľov za tú dobu čo si bol/a preč. Yay!
one: Tiež si získal/a jedného nového následovateľa zatiaľ čo si bol/a preč. Yay!
other: Tiež si získal/a %{count} nových následovateľov za tú dobu čo si bol/a preč. Yay!
subject:
few: "%{count} nové notifikácie od tvojej poslednej návštevy \U0001F418"
one: "1 nová notifikácia od tvojej poslednej návštevy \U0001F418"
other: "%{count} nových notifikácií od tvojej poslednej návštevy \U0001F418"
one: "1 nové oboznámenie od tvojej poslednej návštevy \U0001F418"
other: "%{count} nových oboznámení od tvojej poslednej návštevy \U0001F418"
title: Zatiaľ čo si bol/a preč…
favourite:
body: 'Tvoj príspevok bol uložený medi obľúbené užívateľa %{name}:'
body: 'Tvoj príspevok bol uložený medzi obľúbené užívateľa %{name}:'
subject: "%{name} si obľúbil/a tvoj príspevok"
title: Nové obľúbené
follow:
@ -712,16 +720,16 @@ sk:
action: Spravuj žiadosti o sledovanie
body: "%{name} žiada povolenie ťa následovať"
subject: "%{name} ťa žiadá o možnosť sledovania"
title: Nová žiadosť o sledovanie
title: Nová žiadosť o sledovanie
mention:
action: Odpovedať
body: "%{name} ťa spomenul/a v:"
subject: Bol/a si spomenutý/á užívateľom %{name}
title: Novo spomenutý/á
reblog:
body: 'Tvoj príspevok bol pozdvihnutý užívateľom %{name}:'
subject: "%{name} pozdvihli tvoj príspevok"
title: Novo pozdvyhnuté
body: 'Tvoj príspevok bol vyzdvihnutý užívateľom %{name}:'
subject: "%{name} vyzdvihli tvoj príspevok"
title: Novo vyzdvyhnuté
number:
human:
decimal_units:
@ -820,17 +828,23 @@ sk:
revoke_success: Sezóna úspešne zamietnutá
title: Sezóny
settings:
account: Účet
account_settings: Nastavenia účtu
appearance: Vzhľad
authorized_apps: Povolené aplikácie
back: Späť na Mastodon
delete: Vymazanie účtu
development: Vývoj
edit_profile: Uprav profil
export: Exportovať dáta
featured_tags: Popredne zvýraznené haštagy
import: Importovať
migrate: Presunutie účtu
notifications: Oznámenia
export: Exportuj dáta
featured_tags: Zvýraznené haštagy
import: Importuj
import_and_export: Import a export
migrate: Presuň účet
notifications: Oboznámenia
preferences: Voľby
profile: Profil
relationships: Následovaní a následovatelia
two_factor_authentication: Dvoj-faktorové overenie
statuses:
attached:
@ -846,9 +860,9 @@ sk:
boosted_from_html: Povýšené od %{acct_link}
content_warning: 'Varovanie o obsahu: %{warning}'
disallowed_hashtags:
few: 'obsahoval nepovolené hashtagy: %{tags}'
one: 'obsahoval nepovolený hashtag: %{tags}'
other: 'obsahoval nepovolené hashtagy: %{tags}'
few: 'obsahoval nepovolené haštagy: %{tags}'
one: 'obsahoval nepovolený haštag: %{tags}'
other: 'obsahoval nepovolené haštagy: %{tags}'
language_detection: Zisti automaticky
open_in_web: Otvor v okne na webe
over_character_limit: limit %{max} znakov bol presiahnutý
@ -856,7 +870,7 @@ sk:
limit: Už si si pripol ten najvyšší možný počet hlášok
ownership: Nieje možné pripnúť hlášku od niekoho iného
private: Neverejné príspevky nemôžu byť pripnuté
reblog: Pozdvihnutie sa nedá pripnúť
reblog: Vyzdvihnutie sa nedá pripnúť
poll:
total_votes:
few: "%{count} hlas(y)ov"
@ -874,14 +888,14 @@ sk:
unlisted: Nezaradené
unlisted_long: Všetci môžu vidieť, ale nieje zaradené do verejnej osi
stream_entries:
pinned: Pripnutý toot
pinned: Pripnutý príspevok
reblogged: vyzdvihnutý
sensitive_content: Senzitívny obsah
terms:
body_html: |
<h2>Podmienky súkromia</h2>
<h3 id="collect">Aké informácie zbierame?</h3>
<h3 id="collect">Aké informácie sú zbierané?</h3>
<ul>
<li><em>Základné informácie o účte</em>: Ak sa na tomto serveri zaregistruješ, budeš môcť byť požiadaný/á zadať prezývku, emailovú adresu a heslo. Budeš tiež môcť zadať aj ďalšie profilové údaje, ako napríklad meno a životopis, a nahrať profilovú fotku aj obrázok v záhlaví. Tvoja prezývka, meno, životopis, profilová fotka a obrázok v záhlaví sú vždy zobrazené verejne.</li><li><em>Príspevky, sledovania a iné verejné informácie</em>:
@ -973,7 +987,7 @@ sk:
invalid_otp_token: Neplatný kód pre dvojfaktorovú autentikáciu
otp_lost_help_html: Pokiaľ si stratil/a prístup k obom, môžeš dať vedieť %{email}
seamless_external_login: Si prihlásená/ý cez externú službu, takže nastavenia hesla a emailu ti niesú prístupné.
signed_in_as: 'Prihlásený ako:'
signed_in_as: 'Prihlásená/ý ako:'
verification:
explanation_html: 'Môžeš sa <strong>overiť ako majiteľ odkazov v metadátach tvojho profilu</strong>. Na to musí ale odkazovaná stránka obsahovať odkaz späť na tvoj Mastodon profil. Tento spätný odkaz <strong>musí</strong> mať prívlastok <code>rel="me"</code>. Na texte odkazu nezáleží. Tu je príklad:'
verification: Overenie

View File

@ -0,0 +1,5 @@
class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2]
def change
add_column :media_attachments, :blurhash, :string
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_04_09_054914) do
ActiveRecord::Schema.define(version: 2019_04_20_025523) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -362,6 +362,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
t.bigint "account_id"
t.text "description"
t.bigint "scheduled_status_id"
t.string "blurhash"
t.index ["account_id"], name: "index_media_attachments_on_account_id"
t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true

View File

@ -43,7 +43,7 @@ services:
- external_network
- internal_network
healthcheck:
test: ["CMD-SHELL", "wget -q --spider --header 'x-forwarded-proto: https' --proxy off localhost:3000/api/v1/instance || exit 1"]
test: ["CMD-SHELL", "wget -q --spider --header 'x-forwarded-proto: https' --proxy=off localhost:3000/api/v1/instance || exit 1"]
ports:
- "127.0.0.1:3000:3000"
depends_on:
@ -63,7 +63,7 @@ services:
- external_network
- internal_network
healthcheck:
test: ["CMD-SHELL", "wget -q --spider --header 'x-forwarded-proto: https' --proxy off localhost:4000/api/v1/streaming/health || exit 1"]
test: ["CMD-SHELL", "wget -q --spider --header 'x-forwarded-proto: https' --proxy=off localhost:4000/api/v1/streaming/health || exit 1"]
ports:
- "127.0.0.1:4000:4000"
depends_on:

View File

@ -9,6 +9,7 @@ require_relative 'mastodon/search_cli'
require_relative 'mastodon/settings_cli'
require_relative 'mastodon/statuses_cli'
require_relative 'mastodon/domains_cli'
require_relative 'mastodon/cache_cli'
require_relative 'mastodon/version'
module Mastodon
@ -41,6 +42,9 @@ module Mastodon
desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
subcommand 'domains', Mastodon::DomainsCLI
desc 'cache SUBCOMMAND ...ARGS', 'Manage cache'
subcommand 'cache', Mastodon::CacheCLI
option :dry_run, type: :boolean
desc 'self-destruct', 'Erase the server from the federation'
long_desc <<~LONG_DESC

View File

@ -73,7 +73,7 @@ module Mastodon
def create(username)
account = Account.new(username: username)
password = SecureRandom.hex
user = User.new(email: options[:email], password: password, agreement: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
if options[:reattach]
account = Account.find_local(username) || Account.new(username: username)
@ -115,6 +115,7 @@ module Mastodon
option :enable, type: :boolean
option :disable, type: :boolean
option :disable_2fa, type: :boolean
option :approve, type: :boolean
desc 'modify USERNAME', 'Modify a user'
long_desc <<-LONG_DESC
Modify a user account.
@ -128,6 +129,9 @@ module Mastodon
With the --disable option, lock the user out of their account. The
--enable option is the opposite.
With the --approve option, the account will be approved, if it was
previously not due to not having open registrations.
With the --disable-2fa option, the two-factor authentication
requirement for the user can be removed.
LONG_DESC
@ -147,6 +151,7 @@ module Mastodon
user.email = options[:email] if options[:email]
user.disabled = false if options[:enable]
user.disabled = true if options[:disable]
user.approved = true if options[:approve]
user.otp_required_for_login = false if options[:disable_2fa]
user.confirm if options[:confirm]

19
lib/mastodon/cache_cli.rb Normal file
View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require_relative '../../config/boot'
require_relative '../../config/environment'
require_relative 'cli_helper'
module Mastodon
class CacheCLI < Thor
def self.exit_on_failure?
true
end
desc 'clear', 'Clear out the cache storage'
def clear
Rails.cache.clear
say('OK', :green)
end
end
end

View File

@ -13,7 +13,7 @@ module Mastodon
end
def patch
0
2
end
def pre
@ -33,16 +33,16 @@ module Mastodon
end
def repository
'tootsuite/mastodon'
ENV.fetch('GITHUB_REPOSITORY') { 'tootsuite/mastodon' }
end
def source_base_url
"https://github.com/#{repository}"
ENV.fetch('SOURCE_BASE_URL') { "https://github.com/#{repository}" }
end
# specify git tag or commit hash here
def source_tag
nil
ENV.fetch('SOURCE_TAG') { nil }
end
def source_url

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Paperclip
class BlurhashTranscoder < Paperclip::Processor
def make
return @file unless options[:style] == :small
pixels = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
geometry = options.fetch(:file_geometry_parser).from_file(@file)
attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {})
@file
end
end
end

View File

@ -78,6 +78,7 @@
"babel-plugin-react-intl": "^3.0.1",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"babel-runtime": "^6.26.0",
"blurhash": "^1.0.0",
"classnames": "^2.2.5",
"compression-webpack-plugin": "^2.0.0",
"cross-env": "^5.1.4",

View File

@ -2,3 +2,4 @@
User-agent: *
Disallow: /media_proxy/
Disallow: /interact/

View File

@ -37,7 +37,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
end
it 'renders new when failed to save' do
Fabricate(:domain_block, domain: 'example.com')
Fabricate(:domain_block, domain: 'example.com', severity: 'suspend')
allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } }
@ -45,6 +45,17 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
expect(DomainBlockWorker).not_to have_received(:perform_async)
expect(response).to render_template :new
end
it 'allows upgrading a block' do
Fabricate(:domain_block, domain: 'example.com', severity: 'silence')
allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
post :create, params: { domain_block: { domain: 'example.com', severity: 'silence', reject_media: true, reject_reports: true } }
expect(DomainBlockWorker).to have_received(:perform_async)
expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg')
expect(response).to redirect_to(admin_instances_path(limited: '1'))
end
end
describe 'DELETE #destroy' do

View File

@ -107,6 +107,89 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
end
end
context 'approval-based registrations without invite' do
around do |example|
registrations_mode = Setting.registrations_mode
example.run
Setting.registrations_mode = registrations_mode
end
subject do
Setting.registrations_mode = 'approved'
request.headers["Accept-Language"] = accept_language
post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678' } }
end
it 'redirects to login page' do
subject
expect(response).to redirect_to new_user_session_path
end
it 'creates user' do
subject
user = User.find_by(email: 'test@example.com')
expect(user).to_not be_nil
expect(user.locale).to eq(accept_language)
expect(user.approved).to eq(false)
end
end
context 'approval-based registrations with expired invite' do
around do |example|
registrations_mode = Setting.registrations_mode
example.run
Setting.registrations_mode = registrations_mode
end
subject do
Setting.registrations_mode = 'approved'
request.headers["Accept-Language"] = accept_language
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago)
post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } }
end
it 'redirects to login page' do
subject
expect(response).to redirect_to new_user_session_path
end
it 'creates user' do
subject
user = User.find_by(email: 'test@example.com')
expect(user).to_not be_nil
expect(user.locale).to eq(accept_language)
expect(user.approved).to eq(false)
end
end
context 'approval-based registrations with valid invite' do
around do |example|
registrations_mode = Setting.registrations_mode
example.run
Setting.registrations_mode = registrations_mode
end
subject do
Setting.registrations_mode = 'approved'
request.headers["Accept-Language"] = accept_language
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } }
end
it 'redirects to login page' do
subject
expect(response).to redirect_to new_user_session_path
end
it 'creates user' do
subject
user = User.find_by(email: 'test@example.com')
expect(user).to_not be_nil
expect(user.locale).to eq(accept_language)
expect(user.approved).to eq(true)
end
end
it 'does nothing if user already exists' do
Fabricate(:user, account: Fabricate(:account, username: 'test'))
subject

View File

@ -36,4 +36,35 @@ RSpec.describe DomainBlock, type: :model do
expect(DomainBlock.blocked?('domain')).to eq false
end
end
describe 'stricter_than?' do
it 'returns true if the new block has suspend severity while the old has lower severity' do
suspend = DomainBlock.new(domain: 'domain', severity: :suspend)
silence = DomainBlock.new(domain: 'domain', severity: :silence)
noop = DomainBlock.new(domain: 'domain', severity: :noop)
expect(suspend.stricter_than?(silence)).to be true
expect(suspend.stricter_than?(noop)).to be true
end
it 'returns false if the new block has lower severity than the old one' do
suspend = DomainBlock.new(domain: 'domain', severity: :suspend)
silence = DomainBlock.new(domain: 'domain', severity: :silence)
noop = DomainBlock.new(domain: 'domain', severity: :noop)
expect(silence.stricter_than?(suspend)).to be false
expect(noop.stricter_than?(suspend)).to be false
expect(noop.stricter_than?(silence)).to be false
end
it 'returns false if the new block does is less strict regarding reports' do
older = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: true)
newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: false)
expect(newer.stricter_than?(older)).to be false
end
it 'returns false if the new block does is less strict regarding media' do
older = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: true)
newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: false)
expect(newer.stricter_than?(older)).to be false
end
end
end

View File

@ -8,6 +8,7 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
let(:errors) { double(add: nil) }
before do
allow(user).to receive(:invited?) { false }
allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
described_class.new.validate(user)
end

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