mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-07 20:26:15 +00:00
Compare commits
60 Commits
ffb5ac2640
...
e1af321bda
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e1af321bda | ||
![]() |
fbe9728f36 | ||
![]() |
3bbf3e9709 | ||
![]() |
79931bf3ae | ||
![]() |
22e2e7f02b | ||
![]() |
41d00bc28b | ||
![]() |
3e5d78cc5b | ||
![]() |
df6b808750 | ||
![]() |
aedc5f6921 | ||
![]() |
89cafb01b4 | ||
![]() |
2133f2b47e | ||
![]() |
833ea0725d | ||
![]() |
eacf6f2342 | ||
![]() |
84bca6fd54 | ||
![]() |
cbaba54e9d | ||
![]() |
d41a741e00 | ||
![]() |
03a0f7caf9 | ||
![]() |
8b34daf254 | ||
![]() |
b4394ec129 | ||
![]() |
24c25ec4f5 | ||
![]() |
94fa5b7168 | ||
![]() |
4354f84c5c | ||
![]() |
e3f0b955b8 | ||
![]() |
05f6f7d28a | ||
![]() |
64ab9be93f | ||
![]() |
a2310a06fa | ||
![]() |
79013c730d | ||
![]() |
b81c28e7dc | ||
![]() |
ce13fca0c5 | ||
![]() |
98e6dfcbcf | ||
![]() |
7cb93ef5a1 | ||
![]() |
66d9e47178 | ||
![]() |
e7dd0b37c7 | ||
![]() |
b0e63fbe1c | ||
![]() |
e96044f389 | ||
![]() |
715cbee93d | ||
![]() |
17d8e2b6e3 | ||
![]() |
bd9223f0b9 | ||
![]() |
40157e063d | ||
![]() |
926c67c648 | ||
![]() |
17e4345eb2 | ||
![]() |
9ed6a14d45 | ||
![]() |
1a1f3f037d | ||
![]() |
3032d9d0dd | ||
![]() |
a20686f593 | ||
![]() |
ae3b7dd28d | ||
![]() |
8f59b63176 | ||
![]() |
a97647158c | ||
![]() |
49b6a49c76 | ||
![]() |
d4944a2467 | ||
![]() |
91db45b197 | ||
![]() |
7a70d95435 | ||
![]() |
1326c8cb1d | ||
![]() |
199acce481 | ||
![]() |
b1b949f16c | ||
![]() |
6463415e06 | ||
![]() |
22ec828951 | ||
![]() |
13b13c8726 | ||
![]() |
5679bb5394 | ||
![]() |
1fc66c1970 |
|
@ -63,7 +63,7 @@ docker-compose.override.yml
|
|||
|
||||
# Ignore emoji map file
|
||||
/app/javascript/mastodon/features/emoji/emoji_map.json
|
||||
/app/javascript/mastodon/features/emoji/emoji_sheet.json
|
||||
/app/javascript/mastodon/features/emoji/emoji_data.json
|
||||
|
||||
# Ignore locale files
|
||||
/app/javascript/mastodon/locales/*.json
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.75.2.
|
||||
# using RuboCop version 1.75.3.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
|
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -2,9 +2,34 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.3.8] - 2025-05-06
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
- Check scheme on account, profile, and media URLs ([GHSA-x2rc-v5wx-g3m5](https://github.com/mastodon/mastodon/security/advisories/GHSA-x2rc-v5wx-g3m5))
|
||||
|
||||
### Added
|
||||
|
||||
- Add warning for REDIS_NAMESPACE deprecation at startup (#34581 by @ClearlyClaire)
|
||||
- Add built-in context for interaction policies (#34574 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change activity distribution error handling to skip retrying for deleted accounts (#33617 by @ClearlyClaire)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove double-query for signed query strings (#34610 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549 by @ClearlyClaire)
|
||||
- Fix sign-up e-mail confirmation page reloading on error or redirect (#34548 by @ClearlyClaire)
|
||||
|
||||
## [4.3.7] - 2025-04-02
|
||||
|
||||
### Add
|
||||
### Added
|
||||
|
||||
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
|
||||
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -79,7 +79,7 @@ gem 'rails-i18n', '~> 8.0'
|
|||
gem 'redcarpet', '~> 3.6'
|
||||
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
|
||||
gem 'redis-namespace', '~> 1.10'
|
||||
gem 'rqrcode', '~> 2.2'
|
||||
gem 'rqrcode', '~> 3.0'
|
||||
gem 'ruby-progressbar', '~> 1.13'
|
||||
gem 'sanitize', '~> 7.0'
|
||||
gem 'scenic', '~> 1.7'
|
||||
|
@ -212,7 +212,7 @@ group :development, :test do
|
|||
gem 'test-prof', require: false
|
||||
|
||||
# RSpec runner for rails
|
||||
gem 'rspec-rails', '~> 7.0'
|
||||
gem 'rspec-rails', '~> 8.0'
|
||||
end
|
||||
|
||||
group :production do
|
||||
|
|
40
Gemfile.lock
40
Gemfile.lock
|
@ -160,7 +160,7 @@ GEM
|
|||
cocoon (1.2.15)
|
||||
color_diff (0.1)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.2)
|
||||
connection_pool (2.5.3)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
|
@ -435,7 +435,7 @@ GEM
|
|||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.6)
|
||||
net-imap (0.5.8)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.19.0)
|
||||
|
@ -620,7 +620,7 @@ GEM
|
|||
psych (5.2.3)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
|
@ -711,28 +711,28 @@ GEM
|
|||
rotp (6.3.0)
|
||||
rouge (4.5.1)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (2.2.0)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rspec (3.13.0)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
rspec-expectations (3.13.4)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-github (3.0.0)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-mocks (3.13.2)
|
||||
rspec-mocks (3.13.3)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-rails (8.0.0)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
|
@ -742,8 +742,8 @@ GEM
|
|||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.2)
|
||||
rubocop (1.75.2)
|
||||
rspec-support (3.13.3)
|
||||
rubocop (1.75.5)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
@ -773,7 +773,7 @@ GEM
|
|||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rspec (3.5.0)
|
||||
rubocop-rspec (3.6.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rspec_rails (2.31.0)
|
||||
|
@ -800,14 +800,14 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.31.0)
|
||||
selenium-webdriver (4.32.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
semantic_range (3.1.0)
|
||||
shoulda-matchers (6.4.0)
|
||||
shoulda-matchers (6.5.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (6.5.12)
|
||||
connection_pool (>= 2.2.5, < 3)
|
||||
|
@ -842,7 +842,7 @@ GEM
|
|||
base64
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.6)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.3.0)
|
||||
activerecord (>= 7)
|
||||
swd (2.0.3)
|
||||
|
@ -1043,9 +1043,9 @@ DEPENDENCIES
|
|||
redcarpet (~> 3.6)
|
||||
redis (~> 4.5)
|
||||
redis-namespace (~> 1.10)
|
||||
rqrcode (~> 2.2)
|
||||
rqrcode (~> 3.0)
|
||||
rspec-github (~> 3.0)
|
||||
rspec-rails (~> 7.0)
|
||||
rspec-rails (~> 8.0)
|
||||
rspec-sidekiq (~> 5.0)
|
||||
rubocop
|
||||
rubocop-capybara
|
||||
|
|
|
@ -19,9 +19,16 @@ class AccountsIndex < Chewy::Index
|
|||
type: 'stemmer',
|
||||
language: 'possessive_english',
|
||||
},
|
||||
|
||||
word_joiner: {
|
||||
type: 'shingle',
|
||||
output_unigrams: true,
|
||||
token_separator: '',
|
||||
},
|
||||
},
|
||||
|
||||
analyzer: {
|
||||
# "The FOOING's bar" becomes "foo bar"
|
||||
natural: {
|
||||
tokenizer: 'standard',
|
||||
filter: %w(
|
||||
|
@ -35,11 +42,20 @@ class AccountsIndex < Chewy::Index
|
|||
),
|
||||
},
|
||||
|
||||
# "FOO bar" becomes "foo bar"
|
||||
verbatim: {
|
||||
tokenizer: 'standard',
|
||||
filter: %w(lowercase asciifolding cjk_width),
|
||||
},
|
||||
|
||||
# "Foo bar" becomes "foo bar foobar"
|
||||
word_join_analyzer: {
|
||||
type: 'custom',
|
||||
tokenizer: 'standard',
|
||||
filter: %w(lowercase asciifolding cjk_width word_joiner),
|
||||
},
|
||||
|
||||
# "Foo bar" becomes "f fo foo b ba bar"
|
||||
edge_ngram: {
|
||||
tokenizer: 'edge_ngram',
|
||||
filter: %w(lowercase asciifolding cjk_width),
|
||||
|
|
|
@ -72,6 +72,13 @@ class Api::BaseController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
# Redefine `require_functional!` to properly output JSON instead of HTML redirects
|
||||
def require_functional!
|
||||
return if current_user.functional?
|
||||
|
||||
require_user!
|
||||
end
|
||||
|
||||
def render_empty
|
||||
render json: {}, status: 200
|
||||
end
|
||||
|
|
|
@ -18,7 +18,7 @@ class Api::V1::FeaturedTagsController < Api::BaseController
|
|||
end
|
||||
|
||||
def destroy
|
||||
RemoveFeaturedTagWorker.perform_async(current_account.id, @featured_tag.id)
|
||||
RemoveFeaturedTagService.new.call(current_account, @featured_tag)
|
||||
render_empty
|
||||
end
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::TagsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, except: :show
|
||||
before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, only: [:follow, :unfollow]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:feature, :unfeature]
|
||||
before_action :require_user!, except: :show
|
||||
before_action :set_or_create_tag
|
||||
|
||||
|
@ -23,6 +24,16 @@ class Api::V1::TagsController < Api::BaseController
|
|||
render json: @tag, serializer: REST::TagSerializer
|
||||
end
|
||||
|
||||
def feature
|
||||
CreateFeaturedTagService.new.call(current_account, @tag)
|
||||
render json: @tag, serializer: REST::TagSerializer
|
||||
end
|
||||
|
||||
def unfeature
|
||||
RemoveFeaturedTagService.new.call(current_account, @tag)
|
||||
render json: @tag, serializer: REST::TagSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_or_create_tag
|
||||
|
|
|
@ -72,10 +72,24 @@ class ApplicationController < ActionController::Base
|
|||
def require_functional!
|
||||
return if current_user.functional?
|
||||
|
||||
if current_user.confirmed?
|
||||
redirect_to edit_user_registration_path
|
||||
else
|
||||
redirect_to auth_setup_path
|
||||
respond_to do |format|
|
||||
format.any do
|
||||
if current_user.confirmed?
|
||||
redirect_to edit_user_registration_path
|
||||
else
|
||||
redirect_to auth_setup_path
|
||||
end
|
||||
end
|
||||
|
||||
format.json do
|
||||
if !current_user.confirmed?
|
||||
render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
|
||||
elsif !current_user.approved?
|
||||
render json: { error: 'Your login is currently pending approval' }, status: 403
|
||||
elsif !current_user.functional?
|
||||
render json: { error: 'Your login is currently disabled' }, status: 403
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ module Localized
|
|||
def requested_locale
|
||||
requested_locale_name = available_locale_or_nil(params[:lang])
|
||||
requested_locale_name ||= available_locale_or_nil(current_user.locale) if respond_to?(:user_signed_in?) && user_signed_in?
|
||||
requested_locale_name ||= http_accept_language if ENV['DEFAULT_LOCALE'].blank?
|
||||
requested_locale_name ||= http_accept_language unless ENV['FORCE_DEFAULT_LOCALE'] == 'true'
|
||||
requested_locale_name
|
||||
end
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ class Settings::FeaturedTagsController < Settings::BaseController
|
|||
end
|
||||
|
||||
def create
|
||||
@featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name], force: false)
|
||||
@featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name], raise_error: false)
|
||||
|
||||
if @featured_tag.valid?
|
||||
redirect_to settings_featured_tags_path
|
||||
|
|
|
@ -25,6 +25,14 @@ module ContextHelper
|
|||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
||||
interaction_policies: {
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||
'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' },
|
||||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||
},
|
||||
}.freeze
|
||||
|
||||
def full_context
|
||||
|
|
|
@ -72,6 +72,18 @@ module JsonLdHelper
|
|||
!haystack.casecmp(needle).zero?
|
||||
end
|
||||
|
||||
def safe_prefetched_embed(account, object, context)
|
||||
return unless object.is_a?(Hash)
|
||||
|
||||
# NOTE: Replacing the object's context by that of the parent activity is
|
||||
# not sound, but it's consistent with the rest of the codebase
|
||||
object = object.merge({ '@context' => context })
|
||||
|
||||
return if value_or_id(first_of_value(object['attributedTo'])) != account.uri || non_matching_uri_hosts?(account.uri, object['id'])
|
||||
|
||||
object
|
||||
end
|
||||
|
||||
def canonicalize(json)
|
||||
graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
|
||||
graph.dump(:normalize)
|
||||
|
|
|
@ -4,9 +4,12 @@ import axios from 'axios';
|
|||
import ready from '../mastodon/ready';
|
||||
|
||||
async function checkConfirmation() {
|
||||
const response = await axios.get('/api/v1/emails/check_confirmation');
|
||||
const response = await axios.get('/api/v1/emails/check_confirmation', {
|
||||
headers: { Accept: 'application/json' },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
if (response.status === 200 && response.data === true) {
|
||||
window.location.href = '/start';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M63 45.3v-20c0-4.1-1-7.3-3.2-9.7-2.1-2.4-5-3.7-8.5-3.7-4.1 0-7.2 1.6-9.3 4.7l-2 3.3-2-3.3c-2-3.1-5.1-4.7-9.2-4.7-3.5 0-6.4 1.3-8.6 3.7-2.1 2.4-3.1 5.6-3.1 9.7v20h8V25.9c0-4.1 1.7-6.2 5.2-6.2 3.8 0 5.8 2.5 5.8 7.4V37.7H44V27.1c0-4.9 1.9-7.4 5.8-7.4 3.5 0 5.2 2.1 5.2 6.2V45.3h8ZM74.7 16.6c.6 6 .1 15.7.1 17.3 0 .5-.1 4.8-.1 5.3-.7 11.5-8 16-15.6 17.5-.1 0-.2 0-.3 0-4.9 1-10 1.2-14.9 1.4-1.2 0-2.4 0-3.6 0-4.8 0-9.7-.6-14.4-1.7-.1 0-.1 0-.1 0s-.1 0-.1 0 0 .1 0 .1 0 0 0 0c.1 1.6.4 3.1 1 4.5.6 1.7 2.9 5.7 11.4 5.7 5 0 9.9-.6 14.8-1.7 0 0 0 0 0 0 .1 0 .1 0 .1 0 0 .1 0 .1 0 .1.1 0 .1 0 .1.1v5.6s0 .1-.1.1c0 0 0 0 0 .1-1.6 1.1-3.7 1.7-5.6 2.3-.8.3-1.6.5-2.4.7-7.5 1.7-15.4 1.3-22.7-1.2-6.8-2.4-13.8-8.2-15.5-15.2-.9-3.8-1.6-7.6-1.9-11.5-.6-5.8-.6-11.7-.8-17.5C3.9 24.5 4 20 4.9 16 6.7 7.9 14.1 2.2 22.3 1c1.4-.2 4.1-1 16.5-1h.1C51.4 0 56.7.8 58.1 1c8.4 1.2 15.5 7.5 16.6 15.6Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon"/></svg>
|
||||
|
||||
|
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
@ -1,18 +1,18 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiRemoveAccountFromFollowers } from 'mastodon/api/accounts';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import {
|
||||
apiRemoveAccountFromFollowers,
|
||||
apiGetEndorsedAccounts,
|
||||
} from 'mastodon/api/accounts';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
export const revealAccount = createAction<{
|
||||
id: string;
|
||||
}>('accounts/revealAccount');
|
||||
|
||||
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
|
||||
'accounts/importAccounts',
|
||||
);
|
||||
|
||||
function actionWithSkipLoadingTrue<Args extends object>(args: Args) {
|
||||
return {
|
||||
payload: {
|
||||
|
@ -104,3 +104,12 @@ export const removeAccountFromFollowers = createDataLoadingThunk(
|
|||
apiRemoveAccountFromFollowers(accountId),
|
||||
(relationship) => ({ relationship }),
|
||||
);
|
||||
|
||||
export const fetchEndorsedAccounts = createDataLoadingThunk(
|
||||
'accounts/endorsements',
|
||||
({ accountId }: { accountId: string }) => apiGetEndorsedAccounts(accountId),
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
return data;
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST';
|
||||
export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS';
|
||||
export const FEATURED_TAGS_FETCH_FAIL = 'FEATURED_TAGS_FETCH_FAIL';
|
||||
|
||||
export const fetchFeaturedTags = (id) => (dispatch, getState) => {
|
||||
if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchFeaturedTagsRequest(id));
|
||||
|
||||
api().get(`/api/v1/accounts/${id}/featured_tags`)
|
||||
.then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data)))
|
||||
.catch(err => dispatch(fetchFeaturedTagsFail(id, err)));
|
||||
};
|
||||
|
||||
export const fetchFeaturedTagsRequest = (id) => ({
|
||||
type: FEATURED_TAGS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
export const fetchFeaturedTagsSuccess = (id, tags) => ({
|
||||
type: FEATURED_TAGS_FETCH_SUCCESS,
|
||||
id,
|
||||
tags,
|
||||
});
|
||||
|
||||
export const fetchFeaturedTagsFail = (id, error) => ({
|
||||
type: FEATURED_TAGS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
7
app/javascript/mastodon/actions/featured_tags.ts
Normal file
7
app/javascript/mastodon/actions/featured_tags.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { apiGetFeaturedTags } from 'mastodon/api/accounts';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const fetchFeaturedTags = createDataLoadingThunk(
|
||||
'accounts/featured_tags',
|
||||
({ accountId }: { accountId: string }) => apiGetFeaturedTags(accountId),
|
||||
);
|
7
app/javascript/mastodon/actions/importer/accounts.ts
Normal file
7
app/javascript/mastodon/actions/importer/accounts.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
|
||||
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
|
||||
'accounts/importAccounts',
|
||||
);
|
|
@ -1,7 +1,6 @@
|
|||
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||
|
||||
import { importAccounts } from '../accounts_typed';
|
||||
|
||||
import { importAccounts } from './accounts';
|
||||
import { normalizeStatus } from './normalizer';
|
||||
import { importPolls } from './polls';
|
||||
|
||||
|
|
|
@ -77,6 +77,17 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||
|
||||
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||
normalStatus.url = null;
|
||||
}
|
||||
|
||||
normalStatus.url ||= normalStatus.uri;
|
||||
|
||||
normalStatus.media_attachments.forEach(item => {
|
||||
if (item.remote_url && !(item.remote_url.startsWith('http://') || item.remote_url.startsWith('https://')))
|
||||
item.remote_url = null;
|
||||
});
|
||||
}
|
||||
|
||||
if (normalOldStatus) {
|
||||
|
|
|
@ -4,8 +4,11 @@ import api from '../api';
|
|||
|
||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
||||
import { fetchContext } from './statuses_typed';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
export * from './statuses_typed';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
||||
|
@ -14,10 +17,6 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
|||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||
|
||||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
|
||||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
||||
|
||||
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
|
||||
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
|
||||
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
|
||||
|
@ -54,7 +53,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
|||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||
|
||||
if (alsoFetchContext) {
|
||||
dispatch(fetchContext(id));
|
||||
dispatch(fetchContext({ statusId: id }));
|
||||
}
|
||||
|
||||
if (skipLoading) {
|
||||
|
@ -178,50 +177,6 @@ export function deleteStatusFail(id, error) {
|
|||
export const updateStatus = status => dispatch =>
|
||||
dispatch(importFetchedStatus(status));
|
||||
|
||||
export function fetchContext(id) {
|
||||
return (dispatch) => {
|
||||
dispatch(fetchContextRequest(id));
|
||||
|
||||
api().get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||
dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
|
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
||||
|
||||
}).catch(error => {
|
||||
if (error.response && error.response.status === 404) {
|
||||
dispatch(deleteFromTimelines(id));
|
||||
}
|
||||
|
||||
dispatch(fetchContextFail(id, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchContextRequest(id) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchContextSuccess(id, ancestors, descendants) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_SUCCESS,
|
||||
id,
|
||||
ancestors,
|
||||
descendants,
|
||||
statuses: ancestors.concat(descendants),
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchContextFail(id, error) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function muteStatus(id) {
|
||||
return (dispatch) => {
|
||||
dispatch(muteStatusRequest(id));
|
||||
|
|
18
app/javascript/mastodon/actions/statuses_typed.ts
Normal file
18
app/javascript/mastodon/actions/statuses_typed.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { apiGetContext } from 'mastodon/api/statuses';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedStatuses } from './importer';
|
||||
|
||||
export const fetchContext = createDataLoadingThunk(
|
||||
'status/context',
|
||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
||||
(context, { dispatch }) => {
|
||||
const statuses = context.ancestors.concat(context.descendants);
|
||||
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
|
||||
return {
|
||||
context,
|
||||
};
|
||||
},
|
||||
);
|
|
@ -1,4 +1,10 @@
|
|||
import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags';
|
||||
import {
|
||||
apiGetTag,
|
||||
apiFollowTag,
|
||||
apiUnfollowTag,
|
||||
apiFeatureTag,
|
||||
apiUnfeatureTag,
|
||||
} from 'mastodon/api/tags';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const fetchHashtag = createDataLoadingThunk(
|
||||
|
@ -15,3 +21,13 @@ export const unfollowHashtag = createDataLoadingThunk(
|
|||
'tags/unfollow',
|
||||
({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
|
||||
);
|
||||
|
||||
export const featureHashtag = createDataLoadingThunk(
|
||||
'tags/feature',
|
||||
({ tagId }: { tagId: string }) => apiFeatureTag(tagId),
|
||||
);
|
||||
|
||||
export const unfeatureHashtag = createDataLoadingThunk(
|
||||
'tags/unfeature',
|
||||
({ tagId }: { tagId: string }) => apiUnfeatureTag(tagId),
|
||||
);
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { apiRequestPost } from 'mastodon/api';
|
||||
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
|
||||
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||
|
@ -23,3 +25,9 @@ export const apiRemoveAccountFromFollowers = (id: string) =>
|
|||
apiRequestPost<ApiRelationshipJSON>(
|
||||
`v1/accounts/${id}/remove_from_followers`,
|
||||
);
|
||||
|
||||
export const apiGetFeaturedTags = (id: string) =>
|
||||
apiRequestGet<ApiHashtagJSON>(`v1/accounts/${id}/featured_tags`);
|
||||
|
||||
export const apiGetEndorsedAccounts = (id: string) =>
|
||||
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
|
||||
|
|
5
app/javascript/mastodon/api/statuses.ts
Normal file
5
app/javascript/mastodon/api/statuses.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { apiRequestGet } from 'mastodon/api';
|
||||
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
|
||||
|
||||
export const apiGetContext = (statusId: string) =>
|
||||
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);
|
|
@ -10,6 +10,12 @@ export const apiFollowTag = (tagId: string) =>
|
|||
export const apiUnfollowTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
|
||||
|
||||
export const apiFeatureTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/feature`);
|
||||
|
||||
export const apiUnfeatureTag = (tagId: string) =>
|
||||
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfeature`);
|
||||
|
||||
export const apiGetFollowedTags = async (url?: string) => {
|
||||
const response = await api().request<ApiHashtagJSON[]>({
|
||||
method: 'GET',
|
||||
|
|
|
@ -119,3 +119,8 @@ export interface ApiStatusJSON {
|
|||
card?: ApiPreviewCardJSON;
|
||||
poll?: ApiPollJSON;
|
||||
}
|
||||
|
||||
export interface ApiContextJSON {
|
||||
ancestors: ApiStatusJSON[];
|
||||
descendants: ApiStatusJSON[];
|
||||
}
|
||||
|
|
|
@ -10,4 +10,5 @@ export interface ApiHashtagJSON {
|
|||
url: string;
|
||||
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
|
||||
following?: boolean;
|
||||
featuring?: boolean;
|
||||
}
|
||||
|
|
|
@ -96,13 +96,19 @@ export const decode83 = (str: string) => {
|
|||
return value;
|
||||
};
|
||||
|
||||
export const intToRGB = (int: number) => ({
|
||||
export interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export const intToRGB = (int: number): RGB => ({
|
||||
r: Math.max(0, int >> 16),
|
||||
g: Math.max(0, (int >> 8) & 255),
|
||||
b: Math.max(0, int & 255),
|
||||
});
|
||||
|
||||
export const getAverageFromBlurhash = (blurhash: string) => {
|
||||
export const getAverageFromBlurhash = (blurhash: string | null) => {
|
||||
if (!blurhash) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -105,7 +105,7 @@ class ReportReasonSelector extends PureComponent {
|
|||
};
|
||||
|
||||
componentDidMount() {
|
||||
api(false).get('/api/v1/instance').then(res => {
|
||||
api(false).get('/api/v2/instance').then(res => {
|
||||
this.setState({
|
||||
rules: res.data.rules,
|
||||
});
|
||||
|
|
|
@ -26,11 +26,12 @@ import {
|
|||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { isUserTouching } from 'mastodon/is_mobile';
|
||||
import type {
|
||||
MenuItem,
|
||||
ActionMenuItem,
|
||||
ExternalLinkMenuItem,
|
||||
import {
|
||||
isMenuItem,
|
||||
isActionItem,
|
||||
isExternalLinkItem,
|
||||
} from 'mastodon/models/dropdown_menu';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
|
@ -38,30 +39,6 @@ import { IconButton } from './icon_button';
|
|||
|
||||
let id = 0;
|
||||
|
||||
const isMenuItem = (item: unknown): item is MenuItem => {
|
||||
if (item === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof item === 'object' && 'text' in item;
|
||||
};
|
||||
|
||||
const isActionItem = (item: unknown): item is ActionMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'action' in item;
|
||||
};
|
||||
|
||||
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
|
||||
if (!item || !isMenuItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'href' in item;
|
||||
};
|
||||
|
||||
type RenderItemFn<Item = MenuItem> = (
|
||||
item: Item,
|
||||
index: number,
|
||||
|
@ -320,6 +297,7 @@ interface DropdownProps<Item = MenuItem> {
|
|||
scrollable?: boolean;
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
forceDropdown?: boolean;
|
||||
renderItem?: RenderItemFn<Item>;
|
||||
renderHeader?: RenderHeaderFn<Item>;
|
||||
onOpen?: () => void;
|
||||
|
@ -339,6 +317,7 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
disabled,
|
||||
scrollable,
|
||||
status,
|
||||
forceDropdown = false,
|
||||
renderItem,
|
||||
renderHeader,
|
||||
onOpen,
|
||||
|
@ -354,6 +333,9 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
const open = currentId === openDropdownId;
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
||||
const prefetchAccountId = status
|
||||
? status.getIn(['account', 'id'])
|
||||
: undefined;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (activeElement.current) {
|
||||
|
@ -402,16 +384,15 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
} else {
|
||||
onOpen?.();
|
||||
|
||||
if (status) {
|
||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||
if (prefetchAccountId) {
|
||||
dispatch(fetchRelationships([prefetchAccountId]));
|
||||
}
|
||||
|
||||
if (isUserTouching()) {
|
||||
if (isUserTouching() && !forceDropdown) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACTIONS',
|
||||
modalProps: {
|
||||
status,
|
||||
actions: items,
|
||||
onClick: handleItemClick,
|
||||
},
|
||||
|
@ -431,12 +412,13 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
[
|
||||
dispatch,
|
||||
currentId,
|
||||
prefetchAccountId,
|
||||
scrollKey,
|
||||
onOpen,
|
||||
handleItemClick,
|
||||
open,
|
||||
status,
|
||||
items,
|
||||
forceDropdown,
|
||||
handleClose,
|
||||
],
|
||||
);
|
||||
|
|
|
@ -116,6 +116,7 @@ export const EditedTimestamp: React.FC<{
|
|||
renderHeader={renderHeader}
|
||||
onOpen={handleOpen}
|
||||
onItemClick={handleItemClick}
|
||||
forceDropdown
|
||||
>
|
||||
<button className='dropdown-menu__text-button'>
|
||||
<FormattedMessage
|
||||
|
|
|
@ -1,24 +1,32 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { LoadingIndicator } from './loading_indicator';
|
||||
|
||||
interface Props {
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
visible?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
export const LoadMore: React.FC<Props> = ({
|
||||
onClick,
|
||||
disabled,
|
||||
visible = true,
|
||||
loading = false,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className='load-more'
|
||||
disabled={disabled || !visible}
|
||||
disabled={disabled || loading || !visible}
|
||||
style={{ visibility: visible ? 'visible' : 'hidden' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||
{loading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import CancelPresentationIcon from '@/material-icons/400-24px/cancel_presentation.svg?react';
|
||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
class PictureInPicturePlaceholder extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
aspectRatio: PropTypes.string,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(removePictureInPicture());
|
||||
};
|
||||
|
||||
render () {
|
||||
const { aspectRatio } = this.props;
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture-placeholder' style={{ aspectRatio }} role='button' tabIndex={0} onClick={this.handleClick}>
|
||||
<Icon id='window-restore' icon={CancelPresentationIcon} />
|
||||
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect()(PictureInPicturePlaceholder);
|
|
@ -0,0 +1,46 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import PipExitIcon from '@/material-icons/400-24px/pip_exit.svg?react';
|
||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
export const PictureInPicturePlaceholder: React.FC<{ aspectRatio: string }> = ({
|
||||
aspectRatio,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(removePictureInPicture());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
|
||||
className='picture-in-picture-placeholder'
|
||||
style={{ aspectRatio }}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
>
|
||||
<Icon id='' icon={PipExitIcon} />
|
||||
<FormattedMessage
|
||||
id='picture_in_picture.restore'
|
||||
defaultMessage='Put it back'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -17,7 +17,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
|||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import Card from '../features/status/components/card';
|
||||
|
@ -403,14 +403,7 @@ class Status extends ImmutablePureComponent {
|
|||
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
|
||||
const matchedFilters = status.get('matched_filters');
|
||||
|
||||
if (featured) {
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend__icon'><Icon id='thumb-tack' icon={PushPinIcon} /></div>
|
||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
||||
</div>
|
||||
);
|
||||
} else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
|
||||
prepend = (
|
||||
|
@ -491,9 +484,6 @@ class Status extends ImmutablePureComponent {
|
|||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={110}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||
sensitive={status.get('sensitive')}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
|||
import MediaGallery from 'mastodon/components/media_gallery';
|
||||
import ModalRoot from 'mastodon/components/modal_root';
|
||||
import { Poll } from 'mastodon/components/poll';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import Card from 'mastodon/features/status/components/card';
|
||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
|
|
|
@ -7,19 +7,21 @@ import { useParams } from 'react-router';
|
|||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
|
||||
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
|
||||
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { AccountHeader } from '../account_timeline/components/account_header';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import { EmptyMessage } from './components/empty_message';
|
||||
import { FeaturedTag } from './components/featured_tag';
|
||||
import type { TagMap } from './components/featured_tag';
|
||||
|
@ -29,7 +31,9 @@ interface Params {
|
|||
id?: string;
|
||||
}
|
||||
|
||||
const AccountFeatured = () => {
|
||||
const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
multiColumn,
|
||||
}) => {
|
||||
const accountId = useAccountId();
|
||||
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||
const forceEmptyState = suspended || blockedBy || hidden;
|
||||
|
@ -40,7 +44,8 @@ const AccountFeatured = () => {
|
|||
useEffect(() => {
|
||||
if (accountId) {
|
||||
void dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
dispatch(fetchFeaturedTags(accountId));
|
||||
void dispatch(fetchFeaturedTags({ accountId }));
|
||||
void dispatch(fetchEndorsedAccounts({ accountId }));
|
||||
}
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
|
@ -67,6 +72,17 @@ const AccountFeatured = () => {
|
|||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
);
|
||||
const featuredAccountIds = useAppSelector(
|
||||
(state) =>
|
||||
state.user_lists.getIn(
|
||||
['featured_accounts', accountId, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
);
|
||||
|
||||
if (accountId === null) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
@ -78,7 +94,11 @@ const AccountFeatured = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
|
||||
if (
|
||||
featuredStatusIds.isEmpty() &&
|
||||
featuredTags.isEmpty() &&
|
||||
featuredAccountIds.isEmpty()
|
||||
) {
|
||||
return (
|
||||
<AccountFeaturedWrapper accountId={accountId}>
|
||||
<EmptyMessage
|
||||
|
@ -131,6 +151,19 @@ const AccountFeatured = () => {
|
|||
))}
|
||||
</>
|
||||
)}
|
||||
{!featuredAccountIds.isEmpty() && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
<FormattedMessage
|
||||
id='account.featured.accounts'
|
||||
defaultMessage='Profiles'
|
||||
/>
|
||||
</h4>
|
||||
{featuredAccountIds.map((featuredAccountId) => (
|
||||
<Account key={featuredAccountId} id={featuredAccountId} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<RemoteHint accountId={accountId} />
|
||||
</div>
|
||||
</Column>
|
||||
|
|
|
@ -147,7 +147,7 @@ export const AccountGallery: React.FC<{
|
|||
[dispatch],
|
||||
);
|
||||
|
||||
if (accountId && !isAccount) {
|
||||
if (accountId === null) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
|
|
|
@ -107,7 +107,6 @@ const messages = defineMessages({
|
|||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
|
@ -451,7 +450,6 @@ export const AccountHeader: React.FC<{
|
|||
text: intl.formatMessage(messages.preferences),
|
||||
href: '/settings/preferences',
|
||||
});
|
||||
arr.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
|
||||
arr.push(null);
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
|
|
|
@ -13,7 +13,6 @@ import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
|||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
|
||||
import { lookupAccount, fetchAccount } from '../../actions/accounts';
|
||||
import { fetchFeaturedTags } from '../../actions/featured_tags';
|
||||
import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
|
||||
import { ColumnBackButton } from '../../components/column_back_button';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
|
@ -27,7 +26,7 @@ import { LimitedAccountHint } from './components/limited_account_hint';
|
|||
const emptyList = ImmutableList();
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
|
||||
|
||||
if (accountId === null) {
|
||||
return {
|
||||
|
@ -86,7 +85,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
||||
}
|
||||
|
||||
dispatch(fetchFeaturedTags(accountId));
|
||||
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
|
||||
|
||||
if (accountId === me) {
|
||||
|
|
|
@ -27,7 +27,7 @@ import { Button } from 'mastodon/components/button';
|
|||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||
import { Video, getPointerPosition } from 'mastodon/features/video';
|
||||
|
@ -212,11 +212,11 @@ const Preview: React.FC<{
|
|||
return (
|
||||
<Audio
|
||||
src={media.get('url') as string}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||
poster={
|
||||
(media.get('preview_url') as string | undefined) ??
|
||||
account?.avatar_static
|
||||
}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||
backgroundColor={
|
||||
media.getIn(['meta', 'colors', 'background']) as string
|
||||
}
|
||||
|
|
|
@ -1,588 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { is } from 'immutable';
|
||||
|
||||
import { throttle, debounce } from 'lodash';
|
||||
|
||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||
import PauseIcon from '@/material-icons/400-24px/pause.svg?react';
|
||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||
|
||||
import { Blurhash } from '../../components/blurhash';
|
||||
import { displayMedia, useBlurhash } from '../../initial_state';
|
||||
|
||||
import Visualizer from './visualizer';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||
});
|
||||
|
||||
const TICK_SIZE = 10;
|
||||
const PADDING = 180;
|
||||
|
||||
class Audio extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
poster: PropTypes.string,
|
||||
duration: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
sensitive: PropTypes.bool,
|
||||
editable: PropTypes.bool,
|
||||
fullscreen: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
blurhash: PropTypes.string,
|
||||
cacheWidth: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
onToggleVisibility: PropTypes.func,
|
||||
backgroundColor: PropTypes.string,
|
||||
foregroundColor: PropTypes.string,
|
||||
accentColor: PropTypes.string,
|
||||
currentTime: PropTypes.number,
|
||||
autoPlay: PropTypes.bool,
|
||||
volume: PropTypes.number,
|
||||
muted: PropTypes.bool,
|
||||
deployPictureInPicture: PropTypes.func,
|
||||
matchedFilters: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
state = {
|
||||
width: this.props.width,
|
||||
currentTime: 0,
|
||||
buffer: 0,
|
||||
duration: null,
|
||||
paused: true,
|
||||
muted: false,
|
||||
volume: 1,
|
||||
dragging: false,
|
||||
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
|
||||
};
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.visualizer = new Visualizer(TICK_SIZE);
|
||||
}
|
||||
|
||||
setPlayerRef = c => {
|
||||
this.player = c;
|
||||
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
};
|
||||
|
||||
_pack() {
|
||||
return {
|
||||
src: this.props.src,
|
||||
volume: this.state.volume,
|
||||
muted: this.state.muted,
|
||||
currentTime: this.audio.currentTime,
|
||||
poster: this.props.poster,
|
||||
backgroundColor: this.props.backgroundColor,
|
||||
foregroundColor: this.props.foregroundColor,
|
||||
accentColor: this.props.accentColor,
|
||||
sensitive: this.props.sensitive,
|
||||
visible: this.props.visible,
|
||||
};
|
||||
}
|
||||
|
||||
_setDimensions () {
|
||||
const width = this.player.offsetWidth;
|
||||
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
|
||||
|
||||
if (this.props.cacheWidth) {
|
||||
this.props.cacheWidth(width);
|
||||
}
|
||||
|
||||
this.setState({ width, height });
|
||||
}
|
||||
|
||||
setSeekRef = c => {
|
||||
this.seek = c;
|
||||
};
|
||||
|
||||
setVolumeRef = c => {
|
||||
this.volume = c;
|
||||
};
|
||||
|
||||
setAudioRef = c => {
|
||||
this.audio = c;
|
||||
|
||||
if (this.audio) {
|
||||
this.audio.volume = 1;
|
||||
this.audio.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
|
||||
this.visualizer.setCanvas(c);
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps, prevState) {
|
||||
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
|
||||
this._clear();
|
||||
this._draw();
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
||||
this.setState({ revealed: nextProps.visible });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
|
||||
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
|
||||
this.props.deployPictureInPicture('audio', this._pack());
|
||||
}
|
||||
}
|
||||
|
||||
togglePlay = () => {
|
||||
if (!this.audioContext) {
|
||||
this._initAudioContext();
|
||||
}
|
||||
|
||||
if (this.state.paused) {
|
||||
this.setState({ paused: false }, () => this.audio.play());
|
||||
} else {
|
||||
this.setState({ paused: true }, () => this.audio.pause());
|
||||
}
|
||||
};
|
||||
|
||||
handleResize = debounce(() => {
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
handlePlay = () => {
|
||||
this.setState({ paused: false });
|
||||
|
||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume();
|
||||
}
|
||||
|
||||
this._renderCanvas();
|
||||
};
|
||||
|
||||
handlePause = () => {
|
||||
this.setState({ paused: true });
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.suspend();
|
||||
}
|
||||
};
|
||||
|
||||
handleProgress = () => {
|
||||
const lastTimeRange = this.audio.buffered.length - 1;
|
||||
|
||||
if (lastTimeRange > -1) {
|
||||
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
|
||||
}
|
||||
};
|
||||
|
||||
toggleMute = () => {
|
||||
const muted = !(this.state.muted || this.state.volume === 0);
|
||||
|
||||
this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
toggleReveal = () => {
|
||||
if (this.props.onToggleVisibility) {
|
||||
this.props.onToggleVisibility();
|
||||
} else {
|
||||
this.setState({ revealed: !this.state.revealed });
|
||||
}
|
||||
};
|
||||
|
||||
handleVolumeMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||
|
||||
this.handleMouseVolSlide(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
handleVolumeMouseUp = () => {
|
||||
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
|
||||
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
|
||||
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
|
||||
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||
};
|
||||
|
||||
handleMouseDown = e => {
|
||||
document.addEventListener('mousemove', this.handleMouseMove, true);
|
||||
document.addEventListener('mouseup', this.handleMouseUp, true);
|
||||
document.addEventListener('touchmove', this.handleMouseMove, true);
|
||||
document.addEventListener('touchend', this.handleMouseUp, true);
|
||||
|
||||
this.setState({ dragging: true });
|
||||
this.audio.pause();
|
||||
this.handleMouseMove(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', this.handleMouseMove, true);
|
||||
document.removeEventListener('mouseup', this.handleMouseUp, true);
|
||||
document.removeEventListener('touchmove', this.handleMouseMove, true);
|
||||
document.removeEventListener('touchend', this.handleMouseUp, true);
|
||||
|
||||
this.setState({ dragging: false });
|
||||
this.audio.play();
|
||||
};
|
||||
|
||||
handleMouseMove = throttle(e => {
|
||||
const { x } = getPointerPosition(this.seek, e);
|
||||
const currentTime = this.audio.duration * x;
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
this.setState({ currentTime }, () => {
|
||||
this.audio.currentTime = currentTime;
|
||||
});
|
||||
}
|
||||
}, 15);
|
||||
|
||||
handleTimeUpdate = () => {
|
||||
this.setState({
|
||||
currentTime: this.audio.currentTime,
|
||||
duration: this.audio.duration,
|
||||
});
|
||||
};
|
||||
|
||||
handleMouseVolSlide = throttle(e => {
|
||||
const { x } = getPointerPosition(this.volume, e);
|
||||
|
||||
if(!isNaN(x)) {
|
||||
this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = this.state.muted ? 0 : x;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 15);
|
||||
|
||||
handleScroll = throttle(() => {
|
||||
if (!this.canvas || !this.audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, height } = this.canvas.getBoundingClientRect();
|
||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
||||
|
||||
if (!this.state.paused && !inView) {
|
||||
this.audio.pause();
|
||||
|
||||
if (this.props.deployPictureInPicture) {
|
||||
this.props.deployPictureInPicture('audio', this._pack());
|
||||
}
|
||||
|
||||
this.setState({ paused: true });
|
||||
}
|
||||
}, 150, { trailing: true });
|
||||
|
||||
handleMouseEnter = () => {
|
||||
this.setState({ hovered: true });
|
||||
};
|
||||
|
||||
handleMouseLeave = () => {
|
||||
this.setState({ hovered: false });
|
||||
};
|
||||
|
||||
handleLoadedData = () => {
|
||||
const { autoPlay, currentTime } = this.props;
|
||||
|
||||
if (currentTime) {
|
||||
this.audio.currentTime = currentTime;
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
this.togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
_initAudioContext () {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
const context = new AudioContext();
|
||||
const source = context.createMediaElementSource(this.audio);
|
||||
const gainNode = context.createGain();
|
||||
|
||||
gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
|
||||
|
||||
this.visualizer.setAudioContext(context, source);
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
this.audioContext = context;
|
||||
this.gainNode = gainNode;
|
||||
}
|
||||
|
||||
handleDownload = () => {
|
||||
fetch(this.props.src).then(res => res.blob()).then(blob => {
|
||||
const element = document.createElement('a');
|
||||
const objectURL = URL.createObjectURL(blob);
|
||||
|
||||
element.setAttribute('href', objectURL);
|
||||
element.setAttribute('download', fileNameFromURL(this.props.src));
|
||||
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
|
||||
URL.revokeObjectURL(objectURL);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
_renderCanvas () {
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.audio) return;
|
||||
|
||||
this.handleTimeUpdate();
|
||||
this._clear();
|
||||
this._draw();
|
||||
|
||||
if (!this.state.paused) {
|
||||
this._renderCanvas();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this.visualizer.clear(this.state.width, this.state.height);
|
||||
}
|
||||
|
||||
_draw() {
|
||||
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
|
||||
}
|
||||
|
||||
_getRadius () {
|
||||
return parseInt((this.state.height || this.props.height) / 2 - PADDING * this._getScaleCoefficient());
|
||||
}
|
||||
|
||||
_getScaleCoefficient () {
|
||||
return (this.state.height || this.props.height) / 982;
|
||||
}
|
||||
|
||||
_getCX() {
|
||||
return Math.floor(this.state.width / 2);
|
||||
}
|
||||
|
||||
_getCY() {
|
||||
return Math.floor((this.state.height || this.props.height) / 2);
|
||||
}
|
||||
|
||||
_getAccentColor () {
|
||||
return this.props.accentColor || '#ffffff';
|
||||
}
|
||||
|
||||
_getBackgroundColor () {
|
||||
return this.props.backgroundColor || '#000000';
|
||||
}
|
||||
|
||||
_getForegroundColor () {
|
||||
return this.props.foregroundColor || '#ffffff';
|
||||
}
|
||||
|
||||
seekBy (time) {
|
||||
const currentTime = this.audio.currentTime + time;
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
this.setState({ currentTime }, () => {
|
||||
this.audio.currentTime = currentTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleAudioKeyDown = e => {
|
||||
// On the audio element or the seek bar, we can safely use the space bar
|
||||
// for playback control because there are no buttons to press
|
||||
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.togglePlay();
|
||||
break;
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleMute();
|
||||
break;
|
||||
case 'j':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.seekBy(-10);
|
||||
break;
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.seekBy(10);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
|
||||
const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
|
||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
const muted = this.state.muted || volume === 0;
|
||||
|
||||
return (
|
||||
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
|
||||
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
{(revealed || editable) && <audio
|
||||
src={src}
|
||||
ref={this.setAudioRef}
|
||||
preload={autoPlay ? 'auto' : 'none'}
|
||||
onPlay={this.handlePlay}
|
||||
onPause={this.handlePause}
|
||||
onProgress={this.handleProgress}
|
||||
onLoadedData={this.handleLoadedData}
|
||||
crossOrigin='anonymous'
|
||||
/>}
|
||||
|
||||
<canvas
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='audio-player__canvas'
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
||||
ref={this.setCanvasRef}
|
||||
onClick={this.togglePlay}
|
||||
onKeyDown={this.handleAudioKeyDown}
|
||||
title={alt}
|
||||
aria-label={alt}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
|
||||
|
||||
{(revealed || editable) && <img
|
||||
src={this.props.poster}
|
||||
alt=''
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
|
||||
aspectRatio: '1',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>}
|
||||
|
||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
|
||||
|
||||
<span
|
||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||
tabIndex={0}
|
||||
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
|
||||
onKeyDown={this.handleAudioKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='video-player__controls active'>
|
||||
<div className='video-player__buttons-bar'>
|
||||
<div className='video-player__buttons left'>
|
||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} icon={paused ? PlayArrowIcon : PauseIcon} /></button>
|
||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} icon={muted ? VolumeOffIcon : VolumeUpIcon} /></button>
|
||||
|
||||
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
|
||||
<div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} />
|
||||
|
||||
<span
|
||||
className='video-player__volume__handle'
|
||||
tabIndex={0}
|
||||
style={{ left: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className='video-player__time'>
|
||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{!editable && (
|
||||
<>
|
||||
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
|
||||
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
||||
<Icon id='download' icon={DownloadIcon} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(Audio);
|
840
app/javascript/mastodon/features/audio/index.tsx
Normal file
840
app/javascript/mastodon/features/audio/index.tsx
Normal file
|
@ -0,0 +1,840 @@
|
|||
import { useEffect, useRef, useCallback, useState, useId } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useSpring, animated, config } from '@react-spring/web';
|
||||
|
||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
|
||||
import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react';
|
||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
||||
import Replay5Icon from '@/material-icons/400-24px/replay_5-fill.svg?react';
|
||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition } from 'mastodon/features/video';
|
||||
import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
|
||||
import {
|
||||
displayMedia,
|
||||
useBlurhash,
|
||||
reduceMotion,
|
||||
} from 'mastodon/initial_state';
|
||||
import { playerSettings } from 'mastodon/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||
skipForward: { id: 'video.skip_forward', defaultMessage: 'Skip forward' },
|
||||
skipBackward: { id: 'video.skip_backward', defaultMessage: 'Skip backward' },
|
||||
});
|
||||
|
||||
const persistVolume = (volume: number, muted: boolean) => {
|
||||
playerSettings.set('volume', volume);
|
||||
playerSettings.set('muted', muted);
|
||||
};
|
||||
|
||||
const restoreVolume = (audio: HTMLAudioElement) => {
|
||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
||||
|
||||
audio.volume = volume;
|
||||
audio.muted = muted;
|
||||
};
|
||||
|
||||
const HOVER_FADE_DELAY = 4000;
|
||||
|
||||
export const Audio: React.FC<{
|
||||
src: string;
|
||||
alt?: string;
|
||||
lang?: string;
|
||||
poster?: string;
|
||||
sensitive?: boolean;
|
||||
editable?: boolean;
|
||||
blurhash?: string;
|
||||
visible?: boolean;
|
||||
duration?: number;
|
||||
onToggleVisibility?: () => void;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
accentColor?: string;
|
||||
startTime?: number;
|
||||
startPlaying?: boolean;
|
||||
startVolume?: number;
|
||||
startMuted?: boolean;
|
||||
deployPictureInPicture?: (
|
||||
type: string,
|
||||
mediaProps: {
|
||||
src: string;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
poster?: string;
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
accentColor: string;
|
||||
},
|
||||
) => void;
|
||||
matchedFilters?: string[];
|
||||
}> = ({
|
||||
src,
|
||||
alt,
|
||||
lang,
|
||||
poster,
|
||||
duration,
|
||||
sensitive,
|
||||
editable,
|
||||
blurhash,
|
||||
visible,
|
||||
onToggleVisibility,
|
||||
backgroundColor = '#000000',
|
||||
foregroundColor = '#ffffff',
|
||||
accentColor = '#ffffff',
|
||||
startTime,
|
||||
startPlaying,
|
||||
startVolume,
|
||||
startMuted,
|
||||
deployPictureInPicture,
|
||||
matchedFilters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [loadedDuration, setDuration] = useState(duration ?? 0);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(0.5);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const seekRef = useRef<HTMLDivElement>(null);
|
||||
const volumeRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
|
||||
audioRef,
|
||||
3,
|
||||
);
|
||||
const accessibilityId = useId();
|
||||
|
||||
const [style, spring] = useSpring(() => ({
|
||||
progress: '0%',
|
||||
buffer: '0%',
|
||||
volume: '0%',
|
||||
}));
|
||||
|
||||
const handleAudioRef = useCallback(
|
||||
(c: HTMLVideoElement | null) => {
|
||||
if (audioRef.current && !audioRef.current.paused && c === null) {
|
||||
deployPictureInPicture?.('audio', {
|
||||
src,
|
||||
poster,
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
accentColor,
|
||||
currentTime: audioRef.current.currentTime,
|
||||
muted: audioRef.current.muted,
|
||||
volume: audioRef.current.volume,
|
||||
});
|
||||
}
|
||||
|
||||
audioRef.current = c;
|
||||
|
||||
if (audioRef.current) {
|
||||
restoreVolume(audioRef.current);
|
||||
setVolume(audioRef.current.volume);
|
||||
setMuted(audioRef.current.muted);
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
spring,
|
||||
setVolume,
|
||||
setMuted,
|
||||
src,
|
||||
poster,
|
||||
backgroundColor,
|
||||
accentColor,
|
||||
foregroundColor,
|
||||
deployPictureInPicture,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.muted = muted;
|
||||
}, [volume, muted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof visible !== 'undefined') {
|
||||
setRevealed(visible);
|
||||
} else {
|
||||
setRevealed(
|
||||
displayMedia === 'show_all' ||
|
||||
(displayMedia !== 'hide_all' && !sensitive),
|
||||
);
|
||||
}
|
||||
}, [visible, sensitive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
}
|
||||
}, [suspendAudio, revealed]);
|
||||
|
||||
useEffect(() => {
|
||||
let nextFrame: ReturnType<typeof requestAnimationFrame>;
|
||||
|
||||
const updateProgress = () => {
|
||||
nextFrame = requestAnimationFrame(() => {
|
||||
if (audioRef.current && audioRef.current.duration > 0) {
|
||||
void spring.start({
|
||||
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
config: config.stiff,
|
||||
});
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
});
|
||||
};
|
||||
|
||||
updateProgress();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(nextFrame);
|
||||
};
|
||||
}, [spring]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioRef.current.paused) {
|
||||
resumeAudio();
|
||||
void audioRef.current.play();
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
}
|
||||
}, [resumeAudio, suspendAudio]);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
setPaused(false);
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
setPaused(true);
|
||||
}, []);
|
||||
|
||||
const handleProgress = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastTimeRange = audioRef.current.buffered.length - 1;
|
||||
|
||||
if (lastTimeRange > -1) {
|
||||
void spring.start({
|
||||
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
}, [spring]);
|
||||
|
||||
const handleVolumeChange = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVolume(audioRef.current.volume);
|
||||
setMuted(audioRef.current.muted);
|
||||
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
persistVolume(audioRef.current.volume, audioRef.current.muted);
|
||||
}, [spring, setVolume, setMuted]);
|
||||
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(audioRef.current.currentTime);
|
||||
}, [setCurrentTime]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectivelyMuted =
|
||||
audioRef.current.muted || audioRef.current.volume === 0;
|
||||
|
||||
if (effectivelyMuted) {
|
||||
audioRef.current.muted = false;
|
||||
|
||||
if (audioRef.current.volume === 0) {
|
||||
audioRef.current.volume = 0.05;
|
||||
}
|
||||
} else {
|
||||
audioRef.current.muted = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleReveal = useCallback(() => {
|
||||
if (onToggleVisibility) {
|
||||
onToggleVisibility();
|
||||
} else {
|
||||
setRevealed((value) => !value);
|
||||
}
|
||||
}, [onToggleVisibility, setRevealed]);
|
||||
|
||||
const handleVolumeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const handleVolumeMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleVolumeMouseMove, true);
|
||||
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
};
|
||||
|
||||
const handleVolumeMouseMove = (e: MouseEvent) => {
|
||||
if (!volumeRef.current || !audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x } = getPointerPosition(volumeRef.current, e);
|
||||
|
||||
if (!isNaN(x)) {
|
||||
audioRef.current.volume = x;
|
||||
audioRef.current.muted = x > 0 ? false : true;
|
||||
void spring.start({ volume: `${x * 100}%`, immediate: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleVolumeMouseMove, true);
|
||||
document.addEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
|
||||
handleVolumeMouseMove(e.nativeEvent);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[spring],
|
||||
);
|
||||
|
||||
const handleSeekMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const handleSeekMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleSeekMouseMove, true);
|
||||
document.removeEventListener('mouseup', handleSeekMouseUp, true);
|
||||
|
||||
setDragging(false);
|
||||
resumeAudio();
|
||||
void audioRef.current?.play();
|
||||
};
|
||||
|
||||
const handleSeekMouseMove = (e: MouseEvent) => {
|
||||
if (!seekRef.current || !audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x } = getPointerPosition(seekRef.current, e);
|
||||
const newTime = audioRef.current.duration * x;
|
||||
|
||||
if (!isNaN(newTime)) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
void spring.start({ progress: `${x * 100}%`, immediate: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleSeekMouseMove, true);
|
||||
document.addEventListener('mouseup', handleSeekMouseUp, true);
|
||||
|
||||
setDragging(true);
|
||||
audioRef.current?.pause();
|
||||
handleSeekMouseMove(e.nativeEvent);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[setDragging, spring, resumeAudio],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setHovered(true);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovered(false);
|
||||
}, HOVER_FADE_DELAY);
|
||||
}, [setHovered]);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setHovered(true);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovered(false);
|
||||
}, HOVER_FADE_DELAY);
|
||||
}, [setHovered]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHovered(false);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
}, [setHovered]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setHovered(true);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovered(false);
|
||||
}, HOVER_FADE_DELAY);
|
||||
}, [setHovered]);
|
||||
|
||||
const handleLoadedData = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDuration(audioRef.current.duration);
|
||||
|
||||
if (typeof startTime !== 'undefined') {
|
||||
audioRef.current.currentTime = startTime;
|
||||
}
|
||||
|
||||
if (typeof startVolume !== 'undefined') {
|
||||
audioRef.current.volume = startVolume;
|
||||
}
|
||||
|
||||
if (typeof startMuted !== 'undefined') {
|
||||
audioRef.current.muted = startMuted;
|
||||
}
|
||||
|
||||
if (startPlaying) {
|
||||
void audioRef.current.play();
|
||||
}
|
||||
}, [setDuration, startTime, startVolume, startMuted, startPlaying]);
|
||||
|
||||
const seekBy = (time: number) => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTime = audioRef.current.currentTime + time;
|
||||
|
||||
if (!isNaN(newTime)) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// On the audio element or the seek bar, we can safely use the space bar
|
||||
// for playback control because there are no buttons to press
|
||||
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
}
|
||||
},
|
||||
[togglePlay],
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(() => {
|
||||
seekBy(-5);
|
||||
}, []);
|
||||
|
||||
const handleSkipForward = useCallback(() => {
|
||||
seekBy(5);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const updateVolumeBy = (step: number) => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newVolume = audioRef.current.volume + step;
|
||||
|
||||
if (!isNaN(newVolume)) {
|
||||
audioRef.current.volume = newVolume;
|
||||
audioRef.current.muted = newVolume > 0 ? false : true;
|
||||
}
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case 'k':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
break;
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleMute();
|
||||
break;
|
||||
case 'j':
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(-5);
|
||||
break;
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(5);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateVolumeBy(0.15);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateVolumeBy(-0.15);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[togglePlay, toggleMute],
|
||||
);
|
||||
|
||||
const springForBand0 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand1 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand2 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
|
||||
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
|
||||
const effectivelyMuted = muted || volume === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('audio-player', { inactive: !revealed })}
|
||||
ref={playerRef}
|
||||
style={
|
||||
{
|
||||
'--player-background-color': backgroundColor,
|
||||
'--player-foreground-color': foregroundColor,
|
||||
'--player-accent-color': accentColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
aria-label={alt}
|
||||
lang={lang}
|
||||
>
|
||||
{blurhash && (
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
)}
|
||||
|
||||
<audio /* eslint-disable-line jsx-a11y/media-has-caption */
|
||||
src={src}
|
||||
ref={handleAudioRef}
|
||||
preload={startPlaying ? 'auto' : 'none'}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onProgress={handleProgress}
|
||||
onLoadedData={handleLoadedData}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
crossOrigin='anonymous'
|
||||
/>
|
||||
|
||||
<div
|
||||
className='video-player__seek'
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={progress}
|
||||
aria-valuemax={100}
|
||||
onMouseDown={handleSeekMouseDown}
|
||||
onKeyDownCapture={handleAudioKeyDown}
|
||||
ref={seekRef}
|
||||
role='slider'
|
||||
tabIndex={0}
|
||||
>
|
||||
<animated.div
|
||||
className='video-player__seek__buffer'
|
||||
style={{ width: style.buffer }}
|
||||
/>
|
||||
<animated.div
|
||||
className='video-player__seek__progress'
|
||||
style={{ width: style.progress }}
|
||||
/>
|
||||
|
||||
<animated.span
|
||||
className={classNames('video-player__seek__handle', {
|
||||
active: dragging,
|
||||
})}
|
||||
style={{ left: style.progress }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='audio-player__controls'>
|
||||
<div className='audio-player__controls__play'>
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(messages.skipBackward)}
|
||||
aria-label={intl.formatMessage(messages.skipBackward)}
|
||||
className='player-button'
|
||||
onClick={handleSkipBackward}
|
||||
>
|
||||
<Icon id='' icon={Replay5Icon} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='audio-player__controls__play'>
|
||||
<svg
|
||||
className='audio-player__visualizer'
|
||||
viewBox='0 0 124 124'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={57}
|
||||
cy={62.5}
|
||||
r={springForBand0.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={65}
|
||||
cy={57.5}
|
||||
r={springForBand1.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={63}
|
||||
cy={66.5}
|
||||
r={springForBand2.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
|
||||
<g clipPath={`url(#${accessibilityId}-clip)`}>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill={`url(#${accessibilityId}-pattern)`}
|
||||
/>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill='var(--player-background-color'
|
||||
opacity={0.45}
|
||||
/>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<pattern
|
||||
id={`${accessibilityId}-pattern`}
|
||||
patternContentUnits='objectBoundingBox'
|
||||
width='1'
|
||||
height='1'
|
||||
>
|
||||
<use href={`#${accessibilityId}-image`} />
|
||||
</pattern>
|
||||
|
||||
<clipPath id={`${accessibilityId}-clip`}>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
rx={48}
|
||||
fill='white'
|
||||
/>
|
||||
</clipPath>
|
||||
|
||||
<image
|
||||
id={`${accessibilityId}-image`}
|
||||
href={poster}
|
||||
width={1}
|
||||
height={1}
|
||||
preserveAspectRatio='none'
|
||||
/>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||
aria-label={intl.formatMessage(
|
||||
paused ? messages.play : messages.pause,
|
||||
)}
|
||||
className='player-button'
|
||||
onClick={togglePlay}
|
||||
>
|
||||
<Icon
|
||||
id={paused ? 'play' : 'pause'}
|
||||
icon={paused ? PlayArrowIcon : PauseIcon}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='audio-player__controls__play'>
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(messages.skipForward)}
|
||||
aria-label={intl.formatMessage(messages.skipForward)}
|
||||
className='player-button'
|
||||
onClick={handleSkipForward}
|
||||
>
|
||||
<Icon id='' icon={Forward5Icon} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpoilerButton
|
||||
hidden={revealed || editable}
|
||||
sensitive={sensitive ?? false}
|
||||
onClick={toggleReveal}
|
||||
matchedFilters={matchedFilters}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames('video-player__controls', { active: hovered })}
|
||||
>
|
||||
<div className='video-player__buttons-bar'>
|
||||
<div className='video-player__buttons left'>
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(
|
||||
muted ? messages.unmute : messages.mute,
|
||||
)}
|
||||
aria-label={intl.formatMessage(
|
||||
muted ? messages.unmute : messages.mute,
|
||||
)}
|
||||
className='player-button'
|
||||
onClick={toggleMute}
|
||||
>
|
||||
<Icon
|
||||
id={muted ? 'volume-off' : 'volume-up'}
|
||||
icon={muted ? VolumeOffIcon : VolumeUpIcon}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className='video-player__volume active'
|
||||
ref={volumeRef}
|
||||
onMouseDown={handleVolumeMouseDown}
|
||||
role='slider'
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={effectivelyMuted ? 0 : volume * 100}
|
||||
aria-valuemax={100}
|
||||
tabIndex={0}
|
||||
>
|
||||
<animated.div
|
||||
className='video-player__volume__current'
|
||||
style={{ width: style.volume }}
|
||||
/>
|
||||
|
||||
<animated.span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
style={{ left: style.volume }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className='video-player__time'>
|
||||
<span className='video-player__time-current'>
|
||||
{formatTime(Math.floor(currentTime))}
|
||||
</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>
|
||||
{formatTime(Math.floor(loadedDuration))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{!editable && (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className='player-button'
|
||||
onClick={toggleReveal}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='media_gallery.hide'
|
||||
defaultMessage='Hide'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<a
|
||||
title={intl.formatMessage(messages.download)}
|
||||
aria-label={intl.formatMessage(messages.download)}
|
||||
className='video-player__download__icon player-button'
|
||||
href={src}
|
||||
download
|
||||
>
|
||||
<Icon id='download' icon={DownloadIcon} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Audio;
|
|
@ -1,136 +0,0 @@
|
|||
/*
|
||||
Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
const hex2rgba = (hex, alpha = 1) => {
|
||||
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
export default class Visualizer {
|
||||
|
||||
constructor (tickSize) {
|
||||
this.tickSize = tickSize;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this.canvas = canvas;
|
||||
if (canvas) {
|
||||
this.context = canvas.getContext('2d');
|
||||
}
|
||||
}
|
||||
|
||||
setAudioContext(context, source) {
|
||||
const analyser = context.createAnalyser();
|
||||
|
||||
analyser.smoothingTimeConstant = 0.6;
|
||||
analyser.fftSize = 2048;
|
||||
|
||||
source.connect(analyser);
|
||||
|
||||
this.analyser = analyser;
|
||||
}
|
||||
|
||||
getTickPoints (count) {
|
||||
const coords = [];
|
||||
|
||||
for(let i = 0; i < count; i++) {
|
||||
const rad = Math.PI * 2 * i / count;
|
||||
coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
|
||||
}
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
|
||||
const dx1 = Math.ceil(cx + x1);
|
||||
const dy1 = Math.ceil(cy + y1);
|
||||
const dx2 = Math.ceil(cx + x2);
|
||||
const dy2 = Math.ceil(cy + y2);
|
||||
|
||||
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
|
||||
|
||||
const lastColor = hex2rgba(mainColor, 0);
|
||||
|
||||
gradient.addColorStop(0, mainColor);
|
||||
gradient.addColorStop(0.6, mainColor);
|
||||
gradient.addColorStop(1, lastColor);
|
||||
|
||||
this.context.beginPath();
|
||||
this.context.strokeStyle = gradient;
|
||||
this.context.lineWidth = 2;
|
||||
this.context.moveTo(dx1, dy1);
|
||||
this.context.lineTo(dx2, dy2);
|
||||
this.context.stroke();
|
||||
}
|
||||
|
||||
getTicks (count, size, radius, scaleCoefficient) {
|
||||
const ticks = this.getTickPoints(count);
|
||||
const lesser = 200;
|
||||
const m = [];
|
||||
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
|
||||
const frequencyData = new Uint8Array(bufferLength);
|
||||
const allScales = [];
|
||||
|
||||
if (this.analyser) {
|
||||
this.analyser.getByteFrequencyData(frequencyData);
|
||||
}
|
||||
|
||||
ticks.forEach((tick, i) => {
|
||||
const coef = 1 - i / (ticks.length * 2.5);
|
||||
|
||||
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
|
||||
|
||||
if (delta < 0) {
|
||||
delta = 0;
|
||||
}
|
||||
|
||||
const k = radius / (radius - (size + delta));
|
||||
|
||||
const x1 = tick.x * (radius - size);
|
||||
const y1 = tick.y * (radius - size);
|
||||
const x2 = x1 * k;
|
||||
const y2 = y1 * k;
|
||||
|
||||
m.push({ x1, y1, x2, y2 });
|
||||
|
||||
if (i < 20) {
|
||||
let scale = delta / (200 * scaleCoefficient);
|
||||
scale = scale < 1 ? 1 : scale;
|
||||
allScales.push(scale);
|
||||
}
|
||||
});
|
||||
|
||||
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
|
||||
|
||||
return m.map(({ x1, y1, x2, y2 }) => ({
|
||||
x1: x1,
|
||||
y1: y1,
|
||||
x2: x2 * scale,
|
||||
y2: y2 * scale,
|
||||
}));
|
||||
}
|
||||
|
||||
clear (width, height) {
|
||||
this.context.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
draw (cx, cy, color, radius, coefficient) {
|
||||
this.context.save();
|
||||
|
||||
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
|
||||
|
||||
ticks.forEach(tick => {
|
||||
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
|
||||
});
|
||||
|
||||
this.context.restore();
|
||||
}
|
||||
|
||||
}
|
|
@ -9,7 +9,6 @@ import { useAppDispatch } from 'mastodon/store';
|
|||
|
||||
const messages = defineMessages({
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
|
@ -53,7 +52,6 @@ export const ActionBar: React.FC = () => {
|
|||
text: intl.formatMessage(messages.preferences),
|
||||
href: '/settings/preferences',
|
||||
},
|
||||
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
|
||||
null,
|
||||
{
|
||||
text: intl.formatMessage(messages.follow_requests),
|
||||
|
|
|
@ -40,7 +40,7 @@ let EmojiPicker, Emoji; // load asynchronously
|
|||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||
|
||||
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
|
||||
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15_1.png`;
|
||||
|
||||
const notFoundFn = () => (
|
||||
<div className='emoji-mart-no-results'>
|
||||
|
|
|
@ -30,9 +30,6 @@ const messages = defineMessages({
|
|||
class PrivacyDropdown extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
isUserTouching: PropTypes.func,
|
||||
onModalOpen: PropTypes.func,
|
||||
onModalClose: PropTypes.func,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
noDirect: PropTypes.bool,
|
||||
|
|
|
@ -15,16 +15,6 @@ const mapDispatchToProps = dispatch => ({
|
|||
dispatch(changeComposeVisibility(value));
|
||||
},
|
||||
|
||||
isUserTouching,
|
||||
onModalOpen: props => dispatch(openModal({
|
||||
modalType: 'ACTIONS',
|
||||
modalProps: props,
|
||||
})),
|
||||
onModalClose: () => dispatch(closeModal({
|
||||
modalType: undefined,
|
||||
ignoreFocus: false,
|
||||
})),
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
|
||||
|
|
|
@ -130,6 +130,7 @@ export const Directory: React.FC<{
|
|||
}, [dispatch, order, local]);
|
||||
|
||||
const pinned = !!columnId;
|
||||
const initialLoad = isLoading && accountIds.size === 0;
|
||||
|
||||
const scrollableArea = (
|
||||
<div className='scrollable'>
|
||||
|
@ -170,7 +171,7 @@ export const Directory: React.FC<{
|
|||
</div>
|
||||
|
||||
<div className='directory__list'>
|
||||
{isLoading ? (
|
||||
{initialLoad ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
accountIds.map((accountId) => (
|
||||
|
@ -179,7 +180,11 @@ export const Directory: React.FC<{
|
|||
)}
|
||||
</div>
|
||||
|
||||
<LoadMore onClick={handleLoadMore} visible={!isLoading} />
|
||||
<LoadMore
|
||||
onClick={handleLoadMore}
|
||||
visible={!initialLoad}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
@ -7,94 +7,21 @@
|
|||
|
||||
// This version comment should be bumped each time the emoji data is changed
|
||||
// to ensure that the prevaled file is regenerated by Babel
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
// This json file contains the names of the categories.
|
||||
const emojiMart5LocalesData = require('@emoji-mart/data/i18n/en.json');
|
||||
const emojiMart5Data = require('@emoji-mart/data/sets/15/all.json');
|
||||
const { NimbleEmojiIndex } = require('emoji-mart');
|
||||
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
|
||||
const _ = require('lodash');
|
||||
|
||||
|
||||
let data = require('./emoji_data.json');
|
||||
const emojiMap = require('./emoji_map.json');
|
||||
// This json file is downloaded from https://github.com/iamcal/emoji-data/
|
||||
// and is used to correct the sheet coordinates since we're using that repo's sheet
|
||||
const emojiSheetData = require('./emoji_sheet.json');
|
||||
const { unicodeToFilename } = require('./unicode_to_filename');
|
||||
const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
|
||||
|
||||
// Grabbed from `emoji_utils` to avoid circular dependency
|
||||
function unifiedToNative(unified) {
|
||||
let unicodes = unified.split('-'),
|
||||
codePoints = unicodes.map((u) => `0x${u}`);
|
||||
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
|
||||
let data = {
|
||||
compressed: true,
|
||||
categories: emojiMart5Data.categories.map(cat => {
|
||||
return {
|
||||
...cat,
|
||||
name: emojiMart5LocalesData.categories[cat.id]
|
||||
};
|
||||
}),
|
||||
aliases: emojiMart5Data.aliases,
|
||||
emojis: _(emojiMart5Data.emojis).values().map(emoji => {
|
||||
let skin_variations = {};
|
||||
const unified = emoji.skins[0].unified.toUpperCase();
|
||||
const emojiFromRawData = emojiSheetData.find(e => e.unified === unified);
|
||||
|
||||
if (!emojiFromRawData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (emoji.skins.length > 1) {
|
||||
const [, ...nonDefaultSkins] = emoji.skins;
|
||||
nonDefaultSkins.forEach(skin => {
|
||||
const [matchingRawCodePoints,matchingRawEmoji] = Object.entries(emojiFromRawData.skin_variations).find((pair) => {
|
||||
const [, value] = pair;
|
||||
return value.unified.toLowerCase() === skin.unified;
|
||||
});
|
||||
|
||||
if (matchingRawEmoji && matchingRawCodePoints) {
|
||||
// At the time of writing, the json from `@emoji-mart/data` doesn't have data
|
||||
// for emoji like `woman-heart-woman` with two different skin tones.
|
||||
const skinToneCode = matchingRawCodePoints.split('-')[0];
|
||||
skin_variations[skinToneCode] = {
|
||||
unified: matchingRawEmoji.unified.toUpperCase(),
|
||||
non_qualified: null,
|
||||
sheet_x: matchingRawEmoji.sheet_x,
|
||||
sheet_y: matchingRawEmoji.sheet_y,
|
||||
has_img_twitter: true,
|
||||
native: unifiedToNative(matchingRawEmoji.unified.toUpperCase())
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
a: emoji.name,
|
||||
b: unified,
|
||||
c: undefined,
|
||||
f: true,
|
||||
j: [emoji.id, ...emoji.keywords],
|
||||
k: [emojiFromRawData.sheet_x, emojiFromRawData.sheet_y],
|
||||
m: emoji.emoticons?.[0],
|
||||
l: emoji.emoticons,
|
||||
o: emoji.version,
|
||||
id: emoji.id,
|
||||
skin_variations,
|
||||
native: unifiedToNative(unified.toUpperCase())
|
||||
};
|
||||
}).compact().keyBy(e => e.id).mapValues(e => _.omit(e, 'id')).value()
|
||||
};
|
||||
|
||||
if (data.compressed) {
|
||||
emojiMartUncompress(data);
|
||||
}
|
||||
emojiMartUncompress(data);
|
||||
|
||||
const emojiMartData = data;
|
||||
const emojiIndex = new NimbleEmojiIndex(emojiMartData);
|
||||
|
||||
const excluded = ['®', '©', '™'];
|
||||
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
|
||||
|
@ -103,10 +30,15 @@ const shortcodeMap = {};
|
|||
const shortCodesToEmojiData = {};
|
||||
const emojisWithoutShortCodes = [];
|
||||
|
||||
Object.keys(emojiMart5Data.emojis).forEach(key => {
|
||||
let emoji = emojiMart5Data.emojis[key];
|
||||
Object.keys(emojiIndex.emojis).forEach(key => {
|
||||
let emoji = emojiIndex.emojis[key];
|
||||
|
||||
shortcodeMap[emoji.skins[0].native] = emoji.id;
|
||||
// Emojis with skin tone modifiers are stored like this
|
||||
if (Object.hasOwn(emoji, '1')) {
|
||||
emoji = emoji['1'];
|
||||
}
|
||||
|
||||
shortcodeMap[emoji.native] = emoji.id;
|
||||
});
|
||||
|
||||
const stripModifiers = unicode => {
|
||||
|
@ -150,9 +82,13 @@ Object.keys(emojiMap).forEach(key => {
|
|||
}
|
||||
});
|
||||
|
||||
Object.keys(emojiMartData.emojis).forEach(key => {
|
||||
let emoji = emojiMartData.emojis[key];
|
||||
Object.keys(emojiIndex.emojis).forEach(key => {
|
||||
let emoji = emojiIndex.emojis[key];
|
||||
|
||||
// Emojis with skin tone modifiers are stored like this
|
||||
if (Object.hasOwn(emoji, '1')) {
|
||||
emoji = emoji['1'];
|
||||
}
|
||||
|
||||
const { native } = emoji;
|
||||
let { short_names, search, unified } = emojiMartData.emojis[key];
|
||||
|
|
1
app/javascript/mastodon/features/emoji/emoji_data.json
Normal file
1
app/javascript/mastodon/features/emoji/emoji_data.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -29,7 +29,7 @@ import { LimitedAccountHint } from '../account_timeline/components/limited_accou
|
|||
import Column from '../ui/components/column';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
|
|
|
@ -29,7 +29,7 @@ import { LimitedAccountHint } from '../account_timeline/components/limited_accou
|
|||
import Column from '../ui/components/column';
|
||||
|
||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
||||
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
|
|
|
@ -9,6 +9,8 @@ import {
|
|||
fetchHashtag,
|
||||
followHashtag,
|
||||
unfollowHashtag,
|
||||
featureHashtag,
|
||||
unfeatureHashtag,
|
||||
} from 'mastodon/actions/tags_typed';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
|
@ -28,6 +30,11 @@ const messages = defineMessages({
|
|||
id: 'hashtag.admin_moderation',
|
||||
defaultMessage: 'Open moderation interface for #{name}',
|
||||
},
|
||||
feature: { id: 'hashtag.feature', defaultMessage: 'Feature on profile' },
|
||||
unfeature: {
|
||||
id: 'hashtag.unfeature',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
});
|
||||
|
||||
const usesRenderer = (displayNumber: React.ReactNode, pluralReady: number) => (
|
||||
|
@ -88,22 +95,51 @@ export const HashtagHeader: React.FC<{
|
|||
}, [dispatch, tagId, setTag]);
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const tmp = [];
|
||||
const arr = [];
|
||||
|
||||
if (
|
||||
tag &&
|
||||
signedIn &&
|
||||
(permissions & PERMISSION_MANAGE_TAXONOMIES) ===
|
||||
PERMISSION_MANAGE_TAXONOMIES
|
||||
) {
|
||||
tmp.push({
|
||||
text: intl.formatMessage(messages.adminModeration, { name: tag.id }),
|
||||
href: `/admin/tags/${tag.id}`,
|
||||
if (tag && signedIn) {
|
||||
const handleFeature = () => {
|
||||
if (tag.featuring) {
|
||||
void dispatch(unfeatureHashtag({ tagId })).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
setTag(result.payload);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
} else {
|
||||
void dispatch(featureHashtag({ tagId })).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
setTag(result.payload);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
tag.featuring ? messages.unfeature : messages.feature,
|
||||
),
|
||||
action: handleFeature,
|
||||
});
|
||||
|
||||
arr.push(null);
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_TAXONOMIES) ===
|
||||
PERMISSION_MANAGE_TAXONOMIES
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.adminModeration, { name: tagId }),
|
||||
href: `/admin/tags/${tag.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tmp;
|
||||
}, [signedIn, permissions, intl, tag]);
|
||||
return arr;
|
||||
}, [setTag, dispatch, tagId, signedIn, permissions, intl, tag]);
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!signedIn || !tag) {
|
||||
|
|
|
@ -13,7 +13,6 @@ import { Avatar } from 'mastodon/components/avatar';
|
|||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||
|
@ -27,9 +26,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const status = useAppSelector(
|
||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
||||
);
|
||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
state.accounts.get(status?.get('account') as string),
|
||||
|
|
|
@ -6,7 +6,6 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
|||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
|
@ -39,9 +38,9 @@ export const NotificationMention: React.FC<{
|
|||
unread: boolean;
|
||||
}> = ({ notification, unread }) => {
|
||||
const [isDirect, isReply] = useAppSelector((state) => {
|
||||
const status = state.statuses.get(notification.statusId) as
|
||||
| Status
|
||||
| undefined;
|
||||
const status = notification.statusId
|
||||
? state.statuses.get(notification.statusId)
|
||||
: undefined;
|
||||
|
||||
if (!status) return [false, false] as const;
|
||||
|
||||
|
|
|
@ -1,195 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
const messages = defineMessages({
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
status: getStatus(state, { id: statusId }),
|
||||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
class Footer extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
statusId: PropTypes.string.isRequired,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
withOpenButton: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
_performReply = () => {
|
||||
const { dispatch, status, onClose } = this.props;
|
||||
|
||||
if (onClose) {
|
||||
onClose(true);
|
||||
}
|
||||
|
||||
dispatch(replyCompose(status));
|
||||
};
|
||||
|
||||
handleReplyClick = () => {
|
||||
const { dispatch, askReplyConfirmation, status, onClose } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (askReplyConfirmation) {
|
||||
onClose(true);
|
||||
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
|
||||
} else {
|
||||
this._performReply();
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleFavouriteClick = () => {
|
||||
const { dispatch, status } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (signedIn) {
|
||||
dispatch(toggleFavourite(status.get('id')));
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleReblogClick = e => {
|
||||
const { dispatch, status } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (signedIn) {
|
||||
dispatch(toggleReblog(status.get('id'), e && e.shiftKey));
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleOpenClick = e => {
|
||||
if (e.button !== 0 || !history) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status, onClose } = this.props;
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { status, intl, withOpenButton } = this.props;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||
|
||||
let replyIcon, replyIconComponent, replyTitle;
|
||||
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
replyIcon = 'reply';
|
||||
replyIconComponent = ReplyIcon;
|
||||
replyTitle = intl.formatMessage(messages.reply);
|
||||
} else {
|
||||
replyIcon = 'reply-all';
|
||||
replyIconComponent = ReplyAllIcon;
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
let reblogTitle, reblogIconComponent;
|
||||
|
||||
if (status.get('reblogged')) {
|
||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
|
||||
} else if (publicStatus) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog);
|
||||
reblogIconComponent = RepeatIcon;
|
||||
} else if (reblogPrivate) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||
reblogIconComponent = RepeatPrivateIcon;
|
||||
} else {
|
||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
reblogIconComponent = RepeatDisabledIcon;
|
||||
}
|
||||
|
||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture__footer'>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} iconComponent={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={StarIcon} onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' iconComponent={OpenInNewIcon} onClick={this.handleOpenClick} href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps)(withIdentity(withRouter(injectIntl(Footer))));
|
|
@ -0,0 +1,255 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
|
||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
||||
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_private: {
|
||||
id: 'status.reblog_private',
|
||||
defaultMessage: 'Boost with original visibility',
|
||||
},
|
||||
cancel_reblog_private: {
|
||||
id: 'status.cancel_reblog_private',
|
||||
defaultMessage: 'Unboost',
|
||||
},
|
||||
cannot_reblog: {
|
||||
id: 'status.cannot_reblog',
|
||||
defaultMessage: 'This post cannot be boosted',
|
||||
},
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
removeFavourite: {
|
||||
id: 'status.remove_favourite',
|
||||
defaultMessage: 'Remove from favorites',
|
||||
},
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
});
|
||||
|
||||
export const Footer: React.FC<{
|
||||
statusId: string;
|
||||
withOpenButton?: boolean;
|
||||
onClose: (arg0?: boolean) => void;
|
||||
}> = ({ statusId, withOpenButton, onClose }) => {
|
||||
const { signedIn } = useIdentity();
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||
const accountId = status?.get('account') as string | undefined;
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const askReplyConfirmation = useAppSelector(
|
||||
(state) => (state.compose.get('text') as string).trim().length !== 0,
|
||||
);
|
||||
|
||||
const handleReplyClick = useCallback(() => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
onClose(true);
|
||||
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(
|
||||
openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }),
|
||||
);
|
||||
} else {
|
||||
dispatch(replyCompose(status));
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, status, signedIn, askReplyConfirmation, onClose]);
|
||||
|
||||
const handleFavouriteClick = useCallback(() => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
dispatch(toggleFavourite(status.get('id')));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, status, signedIn]);
|
||||
|
||||
const handleReblogClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, status, signedIn],
|
||||
);
|
||||
|
||||
const handleOpenClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || !status) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
history.push(`/@${account?.acct}/${status.get('id') as string}`);
|
||||
},
|
||||
[history, status, account, onClose],
|
||||
);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(
|
||||
status.get('visibility') as string,
|
||||
);
|
||||
const reblogPrivate =
|
||||
status.getIn(['account', 'id']) === me &&
|
||||
status.get('visibility') === 'private';
|
||||
|
||||
let replyIcon, replyIconComponent, replyTitle;
|
||||
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
replyIcon = 'reply';
|
||||
replyIconComponent = ReplyIcon;
|
||||
replyTitle = intl.formatMessage(messages.reply);
|
||||
} else {
|
||||
replyIcon = 'reply-all';
|
||||
replyIconComponent = ReplyAllIcon;
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
let reblogTitle, reblogIconComponent;
|
||||
|
||||
if (status.get('reblogged')) {
|
||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||
reblogIconComponent = publicStatus
|
||||
? RepeatActiveIcon
|
||||
: RepeatPrivateActiveIcon;
|
||||
} else if (publicStatus) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog);
|
||||
reblogIconComponent = RepeatIcon;
|
||||
} else if (reblogPrivate) {
|
||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||
reblogIconComponent = RepeatPrivateIcon;
|
||||
} else {
|
||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
reblogIconComponent = RepeatDisabledIcon;
|
||||
}
|
||||
|
||||
const favouriteTitle = intl.formatMessage(
|
||||
status.get('favourited') ? messages.removeFavourite : messages.favourite,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture__footer'>
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
title={replyTitle}
|
||||
icon={
|
||||
status.get('in_reply_to_account_id') ===
|
||||
status.getIn(['account', 'id'])
|
||||
? 'reply'
|
||||
: replyIcon
|
||||
}
|
||||
iconComponent={
|
||||
status.get('in_reply_to_account_id') ===
|
||||
status.getIn(['account', 'id'])
|
||||
? ReplyIcon
|
||||
: replyIconComponent
|
||||
}
|
||||
onClick={handleReplyClick}
|
||||
counter={status.get('replies_count') as number}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={classNames('status__action-bar-button', { reblogPrivate })}
|
||||
disabled={!publicStatus && !reblogPrivate}
|
||||
active={status.get('reblogged') as boolean}
|
||||
title={reblogTitle}
|
||||
icon='retweet'
|
||||
iconComponent={reblogIconComponent}
|
||||
onClick={handleReblogClick}
|
||||
counter={status.get('reblogs_count') as number}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className='status__action-bar-button star-icon'
|
||||
animate
|
||||
active={status.get('favourited') as boolean}
|
||||
title={favouriteTitle}
|
||||
icon='star'
|
||||
iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon}
|
||||
onClick={handleFavouriteClick}
|
||||
counter={status.get('favourites_count') as number}
|
||||
/>
|
||||
|
||||
{withOpenButton && (
|
||||
<IconButton
|
||||
className='status__action-bar-button'
|
||||
title={intl.formatMessage(messages.open)}
|
||||
icon='external-link'
|
||||
iconComponent={OpenInNewIcon}
|
||||
onClick={handleOpenClick}
|
||||
href={`/@${account?.acct}/${status.get('id') as string}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,11 +1,11 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
|
||||
|
||||
import Footer from './components/footer';
|
||||
import { Footer } from './components/footer';
|
||||
import { Header } from './components/header';
|
||||
|
||||
export const PictureInPicture: React.FC = () => {
|
||||
|
@ -58,14 +58,14 @@ export const PictureInPicture: React.FC = () => {
|
|||
player = (
|
||||
<Audio
|
||||
src={src}
|
||||
currentTime={currentTime}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
startTime={currentTime}
|
||||
startVolume={volume}
|
||||
startMuted={muted}
|
||||
startPlaying
|
||||
poster={poster}
|
||||
backgroundColor={backgroundColor}
|
||||
foregroundColor={foregroundColor}
|
||||
accentColor={accentColor}
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ export const PictureInPicture: React.FC = () => {
|
|||
|
||||
{player}
|
||||
|
||||
<Footer statusId={statusId} />
|
||||
<Footer statusId={statusId} onClose={handleClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,7 +13,9 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
|
@ -21,17 +23,14 @@ import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
|||
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconLogo } from 'mastodon/components/logo';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import MediaGallery from 'mastodon/components/media_gallery';
|
||||
import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import StatusContent from 'mastodon/components/status_content';
|
||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
|
||||
import Card from './card';
|
||||
|
||||
interface VideoModalOptions {
|
||||
|
@ -189,18 +188,17 @@ export const DetailedStatus: React.FC<{
|
|||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
poster={
|
||||
attachment.get('preview_url') ||
|
||||
status.getIn(['account', 'avatar_static'])
|
||||
}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={showMedia}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
matchedFilters={status.get('matched_media_filters')}
|
||||
/>
|
||||
|
|
|
@ -65,6 +65,7 @@ import { textForScreenReader, defaultMediaVisibility } from '../../components/st
|
|||
import StatusContainer from '../../containers/status_container';
|
||||
import { deleteModal } from '../../initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
|
||||
import { getAncestorsIds, getDescendantsIds } from 'mastodon/selectors/contexts';
|
||||
import Column from '../ui/components/column';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
|
@ -83,69 +84,15 @@ const makeMapStateToProps = () => {
|
|||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
|
||||
const getAncestorsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'inReplyTos']),
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = ImmutableList();
|
||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||
let id = statusId;
|
||||
|
||||
while (id && !mutable.includes(id)) {
|
||||
mutable.unshift(id);
|
||||
id = inReplyTos.get(id);
|
||||
}
|
||||
});
|
||||
|
||||
return ancestorsIds;
|
||||
});
|
||||
|
||||
const getDescendantsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'replies']),
|
||||
state => state.get('statuses'),
|
||||
], (statusId, contextReplies, statuses) => {
|
||||
let descendantsIds = [];
|
||||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
let id = ids.pop();
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (statusId !== id) {
|
||||
descendantsIds.push(id);
|
||||
}
|
||||
|
||||
if (replies) {
|
||||
replies.reverse().forEach(reply => {
|
||||
if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
|
||||
if (insertAt !== -1) {
|
||||
descendantsIds.forEach((id, idx) => {
|
||||
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
|
||||
descendantsIds.splice(idx, 1);
|
||||
descendantsIds.splice(insertAt, 0, id);
|
||||
insertAt += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return ImmutableList(descendantsIds);
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
|
||||
|
||||
let ancestorsIds = ImmutableList();
|
||||
let descendantsIds = ImmutableList();
|
||||
let ancestorsIds = [];
|
||||
let descendantsIds = [];
|
||||
|
||||
if (status) {
|
||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
||||
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
||||
ancestorsIds = getAncestorsIds(state, status.get('in_reply_to_id'));
|
||||
descendantsIds = getDescendantsIds(state, status.get('id'));
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -188,8 +135,8 @@ class Status extends ImmutablePureComponent {
|
|||
dispatch: PropTypes.func.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
isLoading: PropTypes.bool,
|
||||
ancestorsIds: ImmutablePropTypes.list.isRequired,
|
||||
descendantsIds: ImmutablePropTypes.list.isRequired,
|
||||
ancestorsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
descendantsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
|
@ -383,7 +330,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
handleToggleAll = () => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
||||
const statusIds = [status.get('id')].concat(ancestorsIds, descendantsIds);
|
||||
|
||||
if (status.get('hidden')) {
|
||||
this.props.dispatch(revealStatus(statusIds));
|
||||
|
@ -482,13 +429,13 @@ class Status extends ImmutablePureComponent {
|
|||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size - 1, true);
|
||||
this._selectChild(ancestorsIds.length - 1, true);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index, true);
|
||||
this._selectChild(ancestorsIds.length + index, true);
|
||||
} else {
|
||||
this._selectChild(index - 1, true);
|
||||
}
|
||||
|
@ -499,13 +446,13 @@ class Status extends ImmutablePureComponent {
|
|||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size + 1, false);
|
||||
this._selectChild(ancestorsIds.length + 1, false);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index + 2, false);
|
||||
this._selectChild(ancestorsIds.length + index + 2, false);
|
||||
} else {
|
||||
this._selectChild(index + 1, false);
|
||||
}
|
||||
|
@ -536,8 +483,8 @@ class Status extends ImmutablePureComponent {
|
|||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType='thread'
|
||||
previousId={i > 0 ? list.get(i - 1) : undefined}
|
||||
nextId={list.get(i + 1) || (ancestors && statusId)}
|
||||
previousId={i > 0 ? list[i - 1] : undefined}
|
||||
nextId={list[i + 1] || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
/>
|
||||
));
|
||||
|
@ -574,7 +521,7 @@ class Status extends ImmutablePureComponent {
|
|||
componentDidUpdate (prevProps) {
|
||||
const { status, ancestorsIds } = this.props;
|
||||
|
||||
if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) {
|
||||
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
|
||||
this._scrollStatusIntoView();
|
||||
}
|
||||
}
|
||||
|
@ -621,11 +568,11 @@ class Status extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (ancestorsIds && ancestorsIds.size > 0) {
|
||||
if (ancestorsIds && ancestorsIds.length > 0) {
|
||||
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
|
||||
}
|
||||
|
||||
if (descendantsIds && descendantsIds.size > 0) {
|
||||
if (descendantsIds && descendantsIds.length > 0) {
|
||||
descendants = <>{this.renderChildren(descendantsIds)}</>;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
|
||||
export default class ActionsModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
actions: PropTypes.array,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
renderAction = (action, i) => {
|
||||
if (action === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { icon = null, iconComponent = null, text, meta = null, active = false, href = '#' } = action;
|
||||
|
||||
return (
|
||||
<li key={`${text}-${i}`}>
|
||||
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
|
||||
{icon && <IconButton title={text} icon={icon} iconComponent={iconComponent} role='presentation' tabIndex={-1} inverted />}
|
||||
<div>
|
||||
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
|
||||
<div>{meta}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className='modal-root__modal actions-modal'>
|
||||
<ul className={classNames({ 'with-status': !!status })}>
|
||||
{this.props.actions.map(this.renderAction)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import {
|
||||
isActionItem,
|
||||
isExternalLinkItem,
|
||||
} from 'mastodon/models/dropdown_menu';
|
||||
|
||||
export const ActionsModal: React.FC<{
|
||||
actions: MenuItem[];
|
||||
onClick: React.MouseEventHandler;
|
||||
}> = ({ actions, onClick }) => (
|
||||
<div className='modal-root__modal actions-modal'>
|
||||
<ul>
|
||||
{actions.map((option, i: number) => {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, dangerous } = option;
|
||||
|
||||
let element: React.ReactElement;
|
||||
|
||||
if (isActionItem(option)) {
|
||||
element = (
|
||||
<button onClick={onClick} data-index={i}>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
} else if (isExternalLinkItem(option)) {
|
||||
element = (
|
||||
<a
|
||||
href={option.href}
|
||||
target={option.target ?? '_target'}
|
||||
data-method={option.method}
|
||||
rel='noopener'
|
||||
onClick={onClick}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
element = (
|
||||
<Link to={option.to} onClick={onClick} data-index={i}>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames({
|
||||
'dropdown-menu__item--dangerous': dangerous,
|
||||
})}
|
||||
key={`${text}-${i}`}
|
||||
>
|
||||
{element}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
|
@ -1,74 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
status: state.getIn(['statuses', statusId]),
|
||||
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
|
||||
});
|
||||
|
||||
class AudioModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
statusId: PropTypes.string.isRequired,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
accountStaticAvatar: PropTypes.string.isRequired,
|
||||
options: PropTypes.shape({
|
||||
autoPlay: PropTypes.bool,
|
||||
}),
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChangeBackgroundColor: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { media, onChangeBackgroundColor } = this.props;
|
||||
|
||||
const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
|
||||
|
||||
onChangeBackgroundColor(backgroundColor || { r: 255, g: 255, b: 255 });
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.props.onChangeBackgroundColor(null);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, status, accountStaticAvatar, onClose } = this.props;
|
||||
const options = this.props.options || {};
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const description = media.getIn(['translation', 'description']) || media.get('description');
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal audio-modal'>
|
||||
<div className='audio-modal__container'>
|
||||
<Audio
|
||||
src={media.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={150}
|
||||
poster={media.get('preview_url') || accountStaticAvatar}
|
||||
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
||||
autoPlay={options.autoPlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='media-modal__overlay'>
|
||||
{status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, null, null, { forwardRef: true })(AudioModal);
|
|
@ -0,0 +1,78 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||
import type { RGB } from 'mastodon/blurhash';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
const AudioModal: React.FC<{
|
||||
media: MediaAttachment;
|
||||
statusId: string;
|
||||
options: {
|
||||
autoPlay: boolean;
|
||||
};
|
||||
onClose: () => void;
|
||||
onChangeBackgroundColor: (color: RGB | null) => void;
|
||||
}> = ({ media, statusId, options, onClose, onChangeBackgroundColor }) => {
|
||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||
const accountId = status?.get('account') as string | undefined;
|
||||
const accountStaticAvatar = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId)?.avatar_static : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const backgroundColor = getAverageFromBlurhash(
|
||||
media.get('blurhash') as string | null,
|
||||
);
|
||||
|
||||
onChangeBackgroundColor(backgroundColor ?? { r: 255, g: 255, b: 255 });
|
||||
|
||||
return () => {
|
||||
onChangeBackgroundColor(null);
|
||||
};
|
||||
}, [media, onChangeBackgroundColor]);
|
||||
|
||||
const language = (status?.getIn(['translation', 'language']) ??
|
||||
status?.get('language')) as string;
|
||||
const description = (media.getIn(['translation', 'description']) ??
|
||||
media.get('description')) as string;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal audio-modal'>
|
||||
<div className='audio-modal__container'>
|
||||
<Audio
|
||||
src={media.get('url') as string}
|
||||
alt={description}
|
||||
lang={language}
|
||||
poster={
|
||||
(media.get('preview_url') as string | null) ?? accountStaticAvatar
|
||||
}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||
backgroundColor={
|
||||
media.getIn(['meta', 'colors', 'background']) as string
|
||||
}
|
||||
foregroundColor={
|
||||
media.getIn(['meta', 'colors', 'foreground']) as string
|
||||
}
|
||||
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
|
||||
startPlaying={options.autoPlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='media-modal__overlay'>
|
||||
{status && (
|
||||
<Footer
|
||||
statusId={status.get('id') as string}
|
||||
withOpenButton
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AudioModal;
|
|
@ -18,7 +18,7 @@ import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
|||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
import { disableSwiping } from 'mastodon/initial_state';
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
|||
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
|
||||
import ActionsModal from './actions_modal';
|
||||
import { ActionsModal } from './actions_modal';
|
||||
import AudioModal from './audio_modal';
|
||||
import { BoostModal } from './boost_modal';
|
||||
import {
|
||||
|
|
|
@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
|
|
|
@ -806,7 +806,7 @@ export const Video: React.FC<{
|
|||
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
<div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
|
||||
role='menuitem'
|
||||
className={classNames('video-player', {
|
||||
inactive: !revealed,
|
||||
|
@ -820,7 +820,7 @@ export const Video: React.FC<{
|
|||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClickRoot}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{blurhash && (
|
||||
|
@ -845,7 +845,7 @@ export const Video: React.FC<{
|
|||
title={alt}
|
||||
lang={lang}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleVideoKeyDown}
|
||||
onKeyDownCapture={handleVideoKeyDown}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onLoadedData={handleLoadedData}
|
||||
|
|
|
@ -11,27 +11,25 @@ interface Params {
|
|||
id?: string;
|
||||
}
|
||||
|
||||
export function useAccountId() {
|
||||
export const useAccountId = () => {
|
||||
const { acct, id } = useParams<Params>();
|
||||
const dispatch = useAppDispatch();
|
||||
const accountId = useAppSelector(
|
||||
(state) =>
|
||||
id ??
|
||||
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
|
||||
id ?? (acct ? state.accounts_map[normalizeForLookup(acct)] : undefined),
|
||||
);
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const isAccount = !!account;
|
||||
const accountInStore = !!account;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
if (typeof accountId === 'undefined' && acct) {
|
||||
dispatch(lookupAccount(acct));
|
||||
} else if (!isAccount) {
|
||||
} else if (accountId && !accountInStore) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
}, [dispatch, accountId, acct, isAccount]);
|
||||
}, [dispatch, accountId, acct, accountInStore]);
|
||||
|
||||
return accountId;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export function useAccountVisibility(accountId?: string) {
|
||||
const blockedBy = useAppSelector(
|
||||
(state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
|
||||
export function useAccountVisibility(accountId?: string | null) {
|
||||
const blockedBy = useAppSelector((state) =>
|
||||
accountId
|
||||
? !!state.relationships.getIn([accountId, 'blocked_by'], false)
|
||||
: false,
|
||||
);
|
||||
const suspended = useAppSelector(
|
||||
(state) => !!state.accounts.getIn([accountId, 'suspended'], false),
|
||||
const suspended = useAppSelector((state) =>
|
||||
accountId ? !!state.accounts.getIn([accountId, 'suspended'], false) : false,
|
||||
);
|
||||
const hidden = useAppSelector((state) =>
|
||||
accountId ? Boolean(getAccountHidden(state, accountId)) : false,
|
||||
|
|
112
app/javascript/mastodon/hooks/useAudioVisualizer.ts
Normal file
112
app/javascript/mastodon/hooks/useAudioVisualizer.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const normalizeFrequencies = (arr: Float32Array): number[] => {
|
||||
return new Array(...arr).map((value: number) => {
|
||||
if (value === -Infinity) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.sqrt(1 - (Math.max(-100, Math.min(-10, value)) * -1) / 100);
|
||||
});
|
||||
};
|
||||
|
||||
export const useAudioVisualizer = (
|
||||
ref: React.MutableRefObject<HTMLAudioElement | null>,
|
||||
numBands: number,
|
||||
) => {
|
||||
const audioContextRef = useRef<AudioContext>();
|
||||
const sourceRef = useRef<MediaElementAudioSourceNode>();
|
||||
const analyzerRef = useRef<AnalyserNode>();
|
||||
|
||||
const [frequencyBands, setFrequencyBands] = useState<number[]>(
|
||||
new Array(numBands).fill(0),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new AudioContext();
|
||||
analyzerRef.current = audioContextRef.current.createAnalyser();
|
||||
analyzerRef.current.smoothingTimeConstant = 0.6;
|
||||
analyzerRef.current.fftSize = 2048;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
audioContextRef.current &&
|
||||
analyzerRef.current &&
|
||||
!sourceRef.current &&
|
||||
ref.current
|
||||
) {
|
||||
sourceRef.current = audioContextRef.current.createMediaElementSource(
|
||||
ref.current,
|
||||
);
|
||||
sourceRef.current.connect(analyzerRef.current);
|
||||
sourceRef.current.connect(audioContextRef.current.destination);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = sourceRef.current;
|
||||
const analyzer = analyzerRef.current;
|
||||
const context = audioContextRef.current;
|
||||
|
||||
if (!source || !analyzer || !context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bufferLength = analyzer.frequencyBinCount;
|
||||
const frequencyData = new Float32Array(bufferLength);
|
||||
|
||||
const updateProgress = () => {
|
||||
analyzer.getFloatFrequencyData(frequencyData);
|
||||
|
||||
const normalizedFrequencies = normalizeFrequencies(
|
||||
frequencyData.slice(100, 600),
|
||||
);
|
||||
const bands: number[] = [];
|
||||
const chunkSize = Math.ceil(normalizedFrequencies.length / numBands);
|
||||
|
||||
for (let i = 0; i < numBands; i++) {
|
||||
const sum = normalizedFrequencies
|
||||
.slice(i * chunkSize, (i + 1) * chunkSize)
|
||||
.reduce((sum, cur) => sum + cur, 0);
|
||||
bands.push(sum / chunkSize);
|
||||
}
|
||||
|
||||
setFrequencyBands(bands);
|
||||
};
|
||||
|
||||
const updateInterval = setInterval(updateProgress, 15);
|
||||
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
}, [numBands]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.resume();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const suspend = useCallback(() => {
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.suspend();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [resume, suspend, frequencyBands] as const;
|
||||
};
|
|
@ -86,7 +86,6 @@
|
|||
"column.lists": "Lyste",
|
||||
"column.mutes": "Uitgedoofte gebruikers",
|
||||
"column.notifications": "Kennisgewings",
|
||||
"column.pins": "Vasgemaakte plasings",
|
||||
"column.public": "Gefedereerde tydlyn",
|
||||
"column_back_button.label": "Terug",
|
||||
"column_header.hide_settings": "Versteek instellings",
|
||||
|
@ -196,7 +195,6 @@
|
|||
"keyboard_shortcuts.my_profile": "to open your profile",
|
||||
"keyboard_shortcuts.notifications": "Vertoon kennisgewingkolom",
|
||||
"keyboard_shortcuts.open_media": "to open media",
|
||||
"keyboard_shortcuts.pinned": "Vertoon vasgemaakte plasings",
|
||||
"keyboard_shortcuts.profile": "Vertoon skrywersprofiel",
|
||||
"keyboard_shortcuts.reply": "Reageer op plasing",
|
||||
"keyboard_shortcuts.requests": "Sien volgversoeke",
|
||||
|
@ -224,7 +222,6 @@
|
|||
"navigation_bar.lists": "Lyste",
|
||||
"navigation_bar.logout": "Teken uit",
|
||||
"navigation_bar.personal": "Persoonlik",
|
||||
"navigation_bar.pins": "Vasgemaakte plasings",
|
||||
"navigation_bar.preferences": "Voorkeure",
|
||||
"navigation_bar.public_timeline": "Gefedereerde tydlyn",
|
||||
"navigation_bar.search": "Soek",
|
||||
|
@ -262,7 +259,6 @@
|
|||
"status.copy": "Kopieer skakel na hierdie plasing",
|
||||
"status.edited_x_times": "Edited {count, plural, one {# time} other {# times}}",
|
||||
"status.open": "Brei hierdie plasing uit",
|
||||
"status.pinned": "Vasgemaakte plasing",
|
||||
"status.reblog": "Stuur aan",
|
||||
"status.reblog_private": "Stuur aan met oorspronklike sigbaarheid",
|
||||
"status.reblogged_by": "Aangestuur deur {name}",
|
||||
|
|
|
@ -96,7 +96,6 @@
|
|||
"column.lists": "Listas",
|
||||
"column.mutes": "Usuarios silenciaus",
|
||||
"column.notifications": "Notificacions",
|
||||
"column.pins": "Publicacions fixadas",
|
||||
"column.public": "Linia de tiempo federada",
|
||||
"column_back_button.label": "Dezaga",
|
||||
"column_header.hide_settings": "Amagar configuración",
|
||||
|
@ -264,7 +263,6 @@
|
|||
"keyboard_shortcuts.my_profile": "Ubrir lo tuyo perfil",
|
||||
"keyboard_shortcuts.notifications": "Ubrir la columna de notificacions",
|
||||
"keyboard_shortcuts.open_media": "Ubrir fichers multimedia",
|
||||
"keyboard_shortcuts.pinned": "Ubrir la lista de publicacions destacadas",
|
||||
"keyboard_shortcuts.profile": "Ubrir lo perfil de l'autor",
|
||||
"keyboard_shortcuts.reply": "Responder publicación",
|
||||
"keyboard_shortcuts.requests": "Ubrir la lista de peticions de seguidores",
|
||||
|
@ -303,7 +301,6 @@
|
|||
"navigation_bar.logout": "Zarrar sesión",
|
||||
"navigation_bar.mutes": "Usuarios silenciaus",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Publicacions fixadas",
|
||||
"navigation_bar.preferences": "Preferencias",
|
||||
"navigation_bar.public_timeline": "Linia de tiempo federada",
|
||||
"navigation_bar.search": "Buscar",
|
||||
|
@ -452,8 +449,6 @@
|
|||
"status.mute": "Silenciar @{name}",
|
||||
"status.mute_conversation": "Silenciar conversación",
|
||||
"status.open": "Expandir estau",
|
||||
"status.pin": "Fixar",
|
||||
"status.pinned": "Publicación fixada",
|
||||
"status.read_more": "Leyer mas",
|
||||
"status.reblog": "Retutar",
|
||||
"status.reblog_private": "Empentar con l'audiencia orichinal",
|
||||
|
@ -474,7 +469,6 @@
|
|||
"status.translate": "Traducir",
|
||||
"status.translated_from_with": "Traduciu de {lang} usando {provider}",
|
||||
"status.unmute_conversation": "Deixar de silenciar conversación",
|
||||
"status.unpin": "Deixar de fixar",
|
||||
"subscribed_languages.lead": "Nomás los mensaches en os idiomas triaus amaneixerán en o suyo inicio y atras linias de tiempo dimpués d'o cambio. Tríe garra pa recibir mensaches en totz los idiomas.",
|
||||
"subscribed_languages.save": "Alzar cambios",
|
||||
"subscribed_languages.target": "Cambiar idiomas suscritos pa {target}",
|
||||
|
|
|
@ -131,7 +131,6 @@
|
|||
"column.lists": "القوائم",
|
||||
"column.mutes": "المُستَخدِمون المَكتومون",
|
||||
"column.notifications": "الإشعارات",
|
||||
"column.pins": "المنشورات المُثَبَّتَة",
|
||||
"column.public": "الخيط الفيدرالي",
|
||||
"column_back_button.label": "العودة",
|
||||
"column_header.hide_settings": "إخفاء الإعدادات",
|
||||
|
@ -395,7 +394,6 @@
|
|||
"keyboard_shortcuts.my_profile": "لفتح ملفك التعريفي",
|
||||
"keyboard_shortcuts.notifications": "لفتح عمود الإشعارات",
|
||||
"keyboard_shortcuts.open_media": "لفتح الوسائط",
|
||||
"keyboard_shortcuts.pinned": "لفتح قائمة المنشورات المثبتة",
|
||||
"keyboard_shortcuts.profile": "لفتح الملف التعريفي للناشر",
|
||||
"keyboard_shortcuts.reply": "للردّ",
|
||||
"keyboard_shortcuts.requests": "لفتح قائمة طلبات المتابعة",
|
||||
|
@ -464,7 +462,6 @@
|
|||
"navigation_bar.mutes": "الحسابات المكتومة",
|
||||
"navigation_bar.opened_in_classic_interface": "تُفتَح المنشورات والحسابات وغيرها من الصفحات الخاصة بشكل مبدئي على واجهة الويب التقليدية.",
|
||||
"navigation_bar.personal": "شخصي",
|
||||
"navigation_bar.pins": "المنشورات المُثَبَّتَة",
|
||||
"navigation_bar.preferences": "التفضيلات",
|
||||
"navigation_bar.public_timeline": "الخيط الفيدرالي",
|
||||
"navigation_bar.search": "البحث",
|
||||
|
@ -724,8 +721,6 @@
|
|||
"status.mute": "أكتم @{name}",
|
||||
"status.mute_conversation": "كتم المحادثة",
|
||||
"status.open": "وسّع هذا المنشور",
|
||||
"status.pin": "دبّسه على الصفحة التعريفية",
|
||||
"status.pinned": "منشور مثبَّت",
|
||||
"status.read_more": "اقرأ المزيد",
|
||||
"status.reblog": "إعادة النشر",
|
||||
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
|
||||
|
@ -749,7 +744,6 @@
|
|||
"status.translated_from_with": "مترجم من {lang} باستخدام {provider}",
|
||||
"status.uncached_media_warning": "المعاينة غير متوفرة",
|
||||
"status.unmute_conversation": "فك الكتم عن المحادثة",
|
||||
"status.unpin": "فك التدبيس من الصفحة التعريفية",
|
||||
"subscribed_languages.lead": "فقط المنشورات في اللغات المحددة ستظهر في خيطك الرئيسي وتسرد في الجداول الزمنية بعد تأكيد التغيير. لا تقم بأي خيار لتلقي المنشورات في جميع اللغات.",
|
||||
"subscribed_languages.save": "حفظ التغييرات",
|
||||
"subscribed_languages.target": "تغيير اللغات المشتركة لـ {target}",
|
||||
|
|
|
@ -112,7 +112,6 @@
|
|||
"column.lists": "Llistes",
|
||||
"column.mutes": "Perfiles colos avisos desactivaos",
|
||||
"column.notifications": "Avisos",
|
||||
"column.pins": "Artículos fixaos",
|
||||
"column.public": "Llinia de tiempu federada",
|
||||
"column_back_button.label": "Atrás",
|
||||
"column_header.moveLeft_settings": "Mover la columna a la esquierda",
|
||||
|
@ -309,7 +308,6 @@
|
|||
"keyboard_shortcuts.my_profile": "Abrir el to perfil",
|
||||
"keyboard_shortcuts.notifications": "Abrir la columna d'avisos",
|
||||
"keyboard_shortcuts.open_media": "Abrir el conteníu mutimedia",
|
||||
"keyboard_shortcuts.pinned": "Abrir la llista d'artículos fixaos",
|
||||
"keyboard_shortcuts.profile": "Abrir el perfil del autor/a",
|
||||
"keyboard_shortcuts.reply": "Responder a una publicación",
|
||||
"keyboard_shortcuts.requests": "Abrir la llista de solicitúes de siguimientu",
|
||||
|
@ -359,7 +357,6 @@
|
|||
"navigation_bar.mutes": "Perfiles colos avisos desactivaos",
|
||||
"navigation_bar.opened_in_classic_interface": "Los artículos, les cuentes y otres páxines específiques ábrense por defeutu na interfaz web clásica.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Artículos fixaos",
|
||||
"navigation_bar.preferences": "Preferencies",
|
||||
"navigation_bar.public_timeline": "Llinia de tiempu federada",
|
||||
"navigation_bar.security": "Seguranza",
|
||||
|
@ -529,8 +526,6 @@
|
|||
"status.mute": "Desactivar los avisos de @{name}",
|
||||
"status.mute_conversation": "Desactivar los avisos de la conversación",
|
||||
"status.open": "Espander esta publicación",
|
||||
"status.pin": "Fixar nel perfil",
|
||||
"status.pinned": "Publicación fixada",
|
||||
"status.read_more": "Lleer más",
|
||||
"status.reblog": "Compartir",
|
||||
"status.reblogged_by": "{name} compartió",
|
||||
|
@ -548,7 +543,6 @@
|
|||
"status.translated_from_with": "Tradúxose del {lang} con {provider}",
|
||||
"status.uncached_media_warning": "La previsualización nun ta disponible",
|
||||
"status.unmute_conversation": "Activar los avisos de la conversación",
|
||||
"status.unpin": "Lliberar del perfil",
|
||||
"subscribed_languages.save": "Guardar los cambeos",
|
||||
"tabs_bar.home": "Aniciu",
|
||||
"tabs_bar.notifications": "Avisos",
|
||||
|
|
|
@ -157,7 +157,6 @@
|
|||
"column.lists": "Siyahılar",
|
||||
"column.mutes": "Səssizləşdirilmiş istifadəçilər",
|
||||
"column.notifications": "Bildirişlər",
|
||||
"column.pins": "Bərkidilmiş paylaşımlar",
|
||||
"column.public": "Federasiya zaman qrafiki",
|
||||
"column_back_button.label": "Geriyə",
|
||||
"column_header.hide_settings": "Parametrləri gizlət",
|
||||
|
|
|
@ -151,7 +151,6 @@
|
|||
"column.lists": "Спісы",
|
||||
"column.mutes": "Ігнараваныя карыстальнікі",
|
||||
"column.notifications": "Апавяшчэнні",
|
||||
"column.pins": "Замацаваныя допісы",
|
||||
"column.public": "Інтэграваная стужка",
|
||||
"column_back_button.label": "Назад",
|
||||
"column_header.hide_settings": "Схаваць налады",
|
||||
|
@ -440,7 +439,6 @@
|
|||
"keyboard_shortcuts.my_profile": "Адкрыць ваш профіль",
|
||||
"keyboard_shortcuts.notifications": "Адкрыць слупок апавяшчэнняў",
|
||||
"keyboard_shortcuts.open_media": "Адкрыць медыя",
|
||||
"keyboard_shortcuts.pinned": "Адкрыць спіс замацаваных допісаў",
|
||||
"keyboard_shortcuts.profile": "Адкрыць профіль аўтара",
|
||||
"keyboard_shortcuts.reply": "Адказаць на допіс",
|
||||
"keyboard_shortcuts.requests": "Адкрыць спіс запытаў на падпіску",
|
||||
|
@ -505,7 +503,6 @@
|
|||
"navigation_bar.mutes": "Ігнараваныя карыстальнікі",
|
||||
"navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.",
|
||||
"navigation_bar.personal": "Асабістае",
|
||||
"navigation_bar.pins": "Замацаваныя допісы",
|
||||
"navigation_bar.preferences": "Налады",
|
||||
"navigation_bar.public_timeline": "Глабальная стужка",
|
||||
"navigation_bar.search": "Пошук",
|
||||
|
@ -782,8 +779,6 @@
|
|||
"status.mute": "Ігнараваць @{name}",
|
||||
"status.mute_conversation": "Ігнараваць размову",
|
||||
"status.open": "Разгарнуць гэты допіс",
|
||||
"status.pin": "Замацаваць у профілі",
|
||||
"status.pinned": "Замацаваны допіс",
|
||||
"status.read_more": "Чытаць болей",
|
||||
"status.reblog": "Пашырыць",
|
||||
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
|
||||
|
@ -807,7 +802,6 @@
|
|||
"status.translated_from_with": "Перакладзена з {lang} з дапамогай {provider}",
|
||||
"status.uncached_media_warning": "Перадпрагляд недаступны",
|
||||
"status.unmute_conversation": "Не ігнараваць размову",
|
||||
"status.unpin": "Адмацаваць ад профілю",
|
||||
"subscribed_languages.lead": "Толькі допісы ў абраных мовах будуць паказвацца ў вашых стужках пасля змены. Не абірайце нічога, каб бачыць допісы на ўсіх мовах.",
|
||||
"subscribed_languages.save": "Захаваць змены",
|
||||
"subscribed_languages.target": "Змяніць мовы падпіскі для {target}",
|
||||
|
|
|
@ -162,7 +162,6 @@
|
|||
"column.lists": "Списъци",
|
||||
"column.mutes": "Заглушени потребители",
|
||||
"column.notifications": "Известия",
|
||||
"column.pins": "Закачени публикации",
|
||||
"column.public": "Федеративна хронология",
|
||||
"column_back_button.label": "Назад",
|
||||
"column_header.hide_settings": "Скриване на настройките",
|
||||
|
@ -465,7 +464,6 @@
|
|||
"keyboard_shortcuts.my_profile": "Отваряне на профила ви",
|
||||
"keyboard_shortcuts.notifications": "Отваряне на колоната с известия",
|
||||
"keyboard_shortcuts.open_media": "Отваряне на мултимедията",
|
||||
"keyboard_shortcuts.pinned": "Отваряне на списъка със закачени публикации",
|
||||
"keyboard_shortcuts.profile": "Отваряне на профила на автора",
|
||||
"keyboard_shortcuts.reply": "Отговаряне на публикация",
|
||||
"keyboard_shortcuts.requests": "Отваряне на списъка със заявки за последване",
|
||||
|
@ -549,7 +547,6 @@
|
|||
"navigation_bar.mutes": "Заглушени потребители",
|
||||
"navigation_bar.opened_in_classic_interface": "Публикации, акаунти и други особени страници се отварят по подразбиране в класическия мрежови интерфейс.",
|
||||
"navigation_bar.personal": "Лично",
|
||||
"navigation_bar.pins": "Закачени публикации",
|
||||
"navigation_bar.preferences": "Предпочитания",
|
||||
"navigation_bar.public_timeline": "Федеративна хронология",
|
||||
"navigation_bar.search": "Търсене",
|
||||
|
@ -845,8 +842,6 @@
|
|||
"status.mute": "Заглушаване на @{name}",
|
||||
"status.mute_conversation": "Заглушаване на разговора",
|
||||
"status.open": "Разширяване на публикацията",
|
||||
"status.pin": "Закачане в профила",
|
||||
"status.pinned": "Закачена публикация",
|
||||
"status.read_more": "Още за четене",
|
||||
"status.reblog": "Подсилване",
|
||||
"status.reblog_private": "Подсилване с оригиналната видимост",
|
||||
|
@ -871,7 +866,6 @@
|
|||
"status.translated_from_with": "Преведено от {lang}, използвайки {provider}",
|
||||
"status.uncached_media_warning": "Онагледяването не е налично",
|
||||
"status.unmute_conversation": "Без заглушаването на разговора",
|
||||
"status.unpin": "Разкачане от профила",
|
||||
"subscribed_languages.lead": "Публикации само на избрани езици ще се явяват в началото ви и в хронологичните списъци след промяната. Изберете \"нищо\", за да получавате публикации на всички езици.",
|
||||
"subscribed_languages.save": "Запазване на промените",
|
||||
"subscribed_languages.target": "Промяна на абонираните езици за {target}",
|
||||
|
|
|
@ -112,7 +112,6 @@
|
|||
"column.lists": "তালিকাগুলো",
|
||||
"column.mutes": "যাদের কার্যক্রম দেখা বন্ধ আছে",
|
||||
"column.notifications": "প্রজ্ঞাপনগুলো",
|
||||
"column.pins": "পিন করা টুট",
|
||||
"column.public": "যুক্ত সময়রেখা",
|
||||
"column_back_button.label": "পেছনে",
|
||||
"column_header.hide_settings": "সেটিংগুলো সরান",
|
||||
|
@ -258,7 +257,6 @@
|
|||
"keyboard_shortcuts.my_profile": "আপনার নিজের পাতা দেখতে",
|
||||
"keyboard_shortcuts.notifications": "প্রজ্ঞাপনের কলাম খুলতে",
|
||||
"keyboard_shortcuts.open_media": "মিডিয়া খলার জন্য",
|
||||
"keyboard_shortcuts.pinned": "পিন দেওয়া টুটের তালিকা খুলতে",
|
||||
"keyboard_shortcuts.profile": "লেখকের পাতা দেখতে",
|
||||
"keyboard_shortcuts.reply": "মতামত দিতে",
|
||||
"keyboard_shortcuts.requests": "অনুসরণ অনুরোধের তালিকা দেখতে",
|
||||
|
@ -294,7 +292,6 @@
|
|||
"navigation_bar.logout": "বাইরে যান",
|
||||
"navigation_bar.mutes": "যাদের কার্যক্রম দেখা বন্ধ আছে",
|
||||
"navigation_bar.personal": "নিজস্ব",
|
||||
"navigation_bar.pins": "পিন দেওয়া টুট",
|
||||
"navigation_bar.preferences": "পছন্দসমূহ",
|
||||
"navigation_bar.public_timeline": "যুক্তবিশ্বের সময়রেখা",
|
||||
"navigation_bar.search": "অনুসন্ধান",
|
||||
|
@ -389,8 +386,6 @@
|
|||
"status.mute": "@{name}র কার্যক্রম সরিয়ে ফেলতে",
|
||||
"status.mute_conversation": "কথোপকথননের প্রজ্ঞাপন সরিয়ে ফেলতে",
|
||||
"status.open": "এটার সম্পূর্ণটা দেখতে",
|
||||
"status.pin": "নিজের পাতায় এটা পিন করতে",
|
||||
"status.pinned": "পিন করা টুট",
|
||||
"status.read_more": "আরো পড়ুন",
|
||||
"status.reblog": "সমর্থন দিতে",
|
||||
"status.reblog_private": "আপনার অনুসরণকারীদের কাছে এটার সমর্থন দেখাতে",
|
||||
|
@ -408,7 +403,6 @@
|
|||
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}",
|
||||
"status.translate": "অনুবাদ",
|
||||
"status.unmute_conversation": "আলোচনার প্রজ্ঞাপন চালু করতে",
|
||||
"status.unpin": "নিজের পাতা থেকে পিন করে রাখাটির পিন খুলতে",
|
||||
"tabs_bar.home": "বাড়ি",
|
||||
"tabs_bar.notifications": "প্রজ্ঞাপনগুলো",
|
||||
"time_remaining.days": "{number, plural, one {# day} other {# days}} বাকি আছে",
|
||||
|
|
|
@ -124,7 +124,6 @@
|
|||
"column.lists": "Listennoù",
|
||||
"column.mutes": "Implijer·ion·ezed kuzhet",
|
||||
"column.notifications": "Kemennoù",
|
||||
"column.pins": "Embannadurioù spilhennet",
|
||||
"column.public": "Red-amzer kevredet",
|
||||
"column_back_button.label": "Distreiñ",
|
||||
"column_header.hide_settings": "Kuzhat an arventennoù",
|
||||
|
@ -329,7 +328,6 @@
|
|||
"keyboard_shortcuts.my_profile": "Digeriñ ho profil",
|
||||
"keyboard_shortcuts.notifications": "Digeriñ bann ar c'hemennoù",
|
||||
"keyboard_shortcuts.open_media": "Digeriñ ar media",
|
||||
"keyboard_shortcuts.pinned": "Digeriñ listenn an toudoù spilhennet",
|
||||
"keyboard_shortcuts.profile": "Digeriñ profil an aozer.ez",
|
||||
"keyboard_shortcuts.reply": "Respont d'an toud",
|
||||
"keyboard_shortcuts.requests": "Digeriñ roll goulennoù heuliañ",
|
||||
|
@ -378,7 +376,6 @@
|
|||
"navigation_bar.logout": "Digennaskañ",
|
||||
"navigation_bar.mutes": "Implijer·ion·ezed kuzhet",
|
||||
"navigation_bar.personal": "Personel",
|
||||
"navigation_bar.pins": "Toudoù spilhennet",
|
||||
"navigation_bar.preferences": "Gwellvezioù",
|
||||
"navigation_bar.public_timeline": "Red-amzer kevredet",
|
||||
"navigation_bar.search": "Klask",
|
||||
|
@ -578,8 +575,6 @@
|
|||
"status.mute": "Kuzhat @{name}",
|
||||
"status.mute_conversation": "Kuzhat ar gaozeadenn",
|
||||
"status.open": "Digeriñ ar c'hannad-mañ",
|
||||
"status.pin": "Spilhennañ d'ar profil",
|
||||
"status.pinned": "Toud spilhennet",
|
||||
"status.read_more": "Lenn muioc'h",
|
||||
"status.reblog": "Skignañ",
|
||||
"status.reblog_private": "Skignañ gant ar weledenn gentañ",
|
||||
|
@ -601,7 +596,6 @@
|
|||
"status.translated_from_with": "Troet diwar {lang} gant {provider}",
|
||||
"status.uncached_media_warning": "Rakwel n'eo ket da gaout",
|
||||
"status.unmute_conversation": "Diguzhat ar gaozeadenn",
|
||||
"status.unpin": "Dispilhennañ eus ar profil",
|
||||
"subscribed_languages.save": "Enrollañ ar cheñchamantoù",
|
||||
"subscribed_languages.target": "Cheñch ar yezhoù koumanantet evit {target}",
|
||||
"tabs_bar.home": "Degemer",
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
"account.badges.bot": "Bot",
|
||||
"account.cancel_follow_request": "Withdraw follow request",
|
||||
"account_note.placeholder": "Click to add a note",
|
||||
"column.pins": "Pinned post",
|
||||
"community.column_settings.media_only": "Media only",
|
||||
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",
|
||||
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
|
||||
|
@ -34,7 +33,6 @@
|
|||
"keyboard_shortcuts.my_profile": "to open your profile",
|
||||
"keyboard_shortcuts.notifications": "to open notifications column",
|
||||
"keyboard_shortcuts.open_media": "to open media",
|
||||
"keyboard_shortcuts.pinned": "to open pinned posts list",
|
||||
"keyboard_shortcuts.profile": "to open author's profile",
|
||||
"keyboard_shortcuts.reply": "to reply",
|
||||
"keyboard_shortcuts.requests": "to open follow requests list",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"account.enable_notifications": "Notifica'm els tuts de @{name}",
|
||||
"account.endorse": "Recomana en el perfil",
|
||||
"account.featured": "Destacat",
|
||||
"account.featured.accounts": "Perfils",
|
||||
"account.featured.hashtags": "Etiquetes",
|
||||
"account.featured.posts": "Publicacions",
|
||||
"account.featured_tags.last_status_at": "Darrer tut el {date}",
|
||||
|
@ -168,7 +169,7 @@
|
|||
"column.lists": "Llistes",
|
||||
"column.mutes": "Usuaris silenciats",
|
||||
"column.notifications": "Notificacions",
|
||||
"column.pins": "Tuts fixats",
|
||||
"column.pins": "Publicacions destacades",
|
||||
"column.public": "Línia de temps federada",
|
||||
"column_back_button.label": "Enrere",
|
||||
"column_header.hide_settings": "Amaga la configuració",
|
||||
|
@ -405,8 +406,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participant} other {{counter} participants}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} tut} other {{counter} tuts}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} tut} other {{counter} tuts}} avui",
|
||||
"hashtag.feature": "Destaca al perfil",
|
||||
"hashtag.follow": "Segueix l'etiqueta",
|
||||
"hashtag.mute": "Silencia #{hashtag}",
|
||||
"hashtag.unfeature": "No destaquis al perfil",
|
||||
"hashtag.unfollow": "Deixa de seguir l'etiqueta",
|
||||
"hashtags.and_other": "…i {count, plural, other {# més}}",
|
||||
"hints.profiles.followers_may_be_missing": "Es poden haver perdut seguidors d'aquest perfil.",
|
||||
|
@ -476,7 +479,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Obre el teu perfil",
|
||||
"keyboard_shortcuts.notifications": "Obre la columna de notificacions",
|
||||
"keyboard_shortcuts.open_media": "Obre mèdia",
|
||||
"keyboard_shortcuts.pinned": "Obre la llista de tuts fixats",
|
||||
"keyboard_shortcuts.pinned": "Obre la llista de publicacions destacades",
|
||||
"keyboard_shortcuts.profile": "Obre el perfil de l'autor",
|
||||
"keyboard_shortcuts.reply": "Respon al tut",
|
||||
"keyboard_shortcuts.requests": "Obre la llista de sol·licituds de seguiment",
|
||||
|
@ -560,7 +563,7 @@
|
|||
"navigation_bar.mutes": "Usuaris silenciats",
|
||||
"navigation_bar.opened_in_classic_interface": "Els tuts, comptes i altres pàgines especifiques s'obren per defecte en la interfície web clàssica.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Tuts fixats",
|
||||
"navigation_bar.pins": "Publicacions destacades",
|
||||
"navigation_bar.preferences": "Preferències",
|
||||
"navigation_bar.public_timeline": "Línia de temps federada",
|
||||
"navigation_bar.search": "Cerca",
|
||||
|
@ -856,8 +859,7 @@
|
|||
"status.mute": "Silencia @{name}",
|
||||
"status.mute_conversation": "Silencia la conversa",
|
||||
"status.open": "Amplia el tut",
|
||||
"status.pin": "Fixa en el perfil",
|
||||
"status.pinned": "Tut fixat",
|
||||
"status.pin": "Destaca al perfil",
|
||||
"status.read_more": "Més informació",
|
||||
"status.reblog": "Impulsa",
|
||||
"status.reblog_private": "Impulsa amb la visibilitat original",
|
||||
|
@ -882,7 +884,7 @@
|
|||
"status.translated_from_with": "Traduït del {lang} fent servir {provider}",
|
||||
"status.uncached_media_warning": "Previsualització no disponible",
|
||||
"status.unmute_conversation": "Deixa de silenciar la conversa",
|
||||
"status.unpin": "Desfixa del perfil",
|
||||
"status.unpin": "No destaquis al perfil",
|
||||
"subscribed_languages.lead": "Només els tuts en les llengües seleccionades apareixeran en les teves línies de temps \"Inici\" i \"Llistes\" després del canvi. No en seleccionis cap per a rebre tuts en totes les llengües.",
|
||||
"subscribed_languages.save": "Desa els canvis",
|
||||
"subscribed_languages.target": "Canvia les llengües subscrites per a {target}",
|
||||
|
|
|
@ -112,7 +112,6 @@
|
|||
"column.lists": "پێرست",
|
||||
"column.mutes": "بێدەنگکردنی بەکارهێنەران",
|
||||
"column.notifications": "ئاگادارییەکان",
|
||||
"column.pins": "تووتسی چەسپاو",
|
||||
"column.public": "نووسراوەکانی هەمووشوێنێک",
|
||||
"column_back_button.label": "دواوە",
|
||||
"column_header.hide_settings": "شاردنەوەی ڕێکخستنەکان",
|
||||
|
@ -308,7 +307,6 @@
|
|||
"keyboard_shortcuts.my_profile": "بۆ کردنەوەی پرۆفایڵ",
|
||||
"keyboard_shortcuts.notifications": "بۆ کردنەوەی ستوونی ئاگانامەکان",
|
||||
"keyboard_shortcuts.open_media": "بۆ کردنەوەی میدیا",
|
||||
"keyboard_shortcuts.pinned": "بۆ کردنەوەی لیستی توتەکانی چەسپێنراو",
|
||||
"keyboard_shortcuts.profile": "بۆ کردنەوەی پرۆفایڵی نووسەر",
|
||||
"keyboard_shortcuts.reply": "بۆ وەڵامدانەوە",
|
||||
"keyboard_shortcuts.requests": "بۆ کردنەوەی لیستی داواکاریەکانی بەدوادا",
|
||||
|
@ -349,7 +347,6 @@
|
|||
"navigation_bar.logout": "دەرچوون",
|
||||
"navigation_bar.mutes": "کپکردنی بەکارهێنەران",
|
||||
"navigation_bar.personal": "کەسی",
|
||||
"navigation_bar.pins": "توتی چەسپاو",
|
||||
"navigation_bar.preferences": "پەسەندەکان",
|
||||
"navigation_bar.public_timeline": "نووسراوەکانی هەمووشوێنێک",
|
||||
"navigation_bar.search": "گەڕان",
|
||||
|
@ -508,8 +505,6 @@
|
|||
"status.mute": "@{name} بێدەنگ بکە",
|
||||
"status.mute_conversation": "بێدەنگی بکە",
|
||||
"status.open": "ئەم توتە فراوان بکە",
|
||||
"status.pin": "لکاندن لەسەر پرۆفایل",
|
||||
"status.pinned": "توتی چەسپکراو",
|
||||
"status.read_more": "زیاتر بخوێنەوە",
|
||||
"status.reblog": "بەهێزکردن",
|
||||
"status.reblog_private": "بەهێزکردن بۆ بینەرانی سەرەتایی",
|
||||
|
@ -530,7 +525,6 @@
|
|||
"status.translate": "وەریبگێرە",
|
||||
"status.translated_from_with": "لە {lang} وەرگێڕدراوە بە بەکارهێنانی {provider}",
|
||||
"status.unmute_conversation": "گفتوگۆی بێدەنگ",
|
||||
"status.unpin": "لە سەرەوە لایبە",
|
||||
"subscribed_languages.lead": "تەنها پۆستەکان بە زمانە هەڵبژێردراوەکان لە ماڵەکەتدا دەردەکەون و هێڵەکانی کاتی لیستەکەت دوای گۆڕانکارییەکە. هیچیان هەڵبژێرە بۆ وەرگرتنی پۆست بە هەموو زمانەکان.",
|
||||
"subscribed_languages.save": "پاشکەوتی گۆڕانکاریەکان",
|
||||
"subscribed_languages.target": "گۆڕینی زمانە بەشداربووەکان بۆ {target}",
|
||||
|
|
|
@ -53,7 +53,6 @@
|
|||
"column.lists": "Liste",
|
||||
"column.mutes": "Utilizatori piattati",
|
||||
"column.notifications": "Nutificazione",
|
||||
"column.pins": "Statuti puntarulati",
|
||||
"column.public": "Linea pubblica glubale",
|
||||
"column_back_button.label": "Ritornu",
|
||||
"column_header.hide_settings": "Piattà i parametri",
|
||||
|
@ -178,7 +177,6 @@
|
|||
"keyboard_shortcuts.my_profile": "per apre u vostru prufile",
|
||||
"keyboard_shortcuts.notifications": "per apre a culonna di nutificazione",
|
||||
"keyboard_shortcuts.open_media": "per apre i media",
|
||||
"keyboard_shortcuts.pinned": "per apre a lista di statuti puntarulati",
|
||||
"keyboard_shortcuts.profile": "per apre u prufile di l'autore",
|
||||
"keyboard_shortcuts.reply": "risponde",
|
||||
"keyboard_shortcuts.requests": "per apre a lista di dumande d'abbunamentu",
|
||||
|
@ -212,7 +210,6 @@
|
|||
"navigation_bar.logout": "Scunnettassi",
|
||||
"navigation_bar.mutes": "Utilizatori piattati",
|
||||
"navigation_bar.personal": "Persunale",
|
||||
"navigation_bar.pins": "Statuti puntarulati",
|
||||
"navigation_bar.preferences": "Preferenze",
|
||||
"navigation_bar.public_timeline": "Linea pubblica glubale",
|
||||
"navigation_bar.security": "Sicurità",
|
||||
|
@ -296,8 +293,6 @@
|
|||
"status.mute": "Piattà @{name}",
|
||||
"status.mute_conversation": "Piattà a cunversazione",
|
||||
"status.open": "Apre stu statutu",
|
||||
"status.pin": "Puntarulà à u prufile",
|
||||
"status.pinned": "Statutu puntarulatu",
|
||||
"status.read_more": "Leghje di più",
|
||||
"status.reblog": "Sparte",
|
||||
"status.reblog_private": "Sparte à l'audienza uriginale",
|
||||
|
@ -314,7 +309,6 @@
|
|||
"status.show_more_all": "Slibrà tuttu",
|
||||
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}",
|
||||
"status.unmute_conversation": "Ùn piattà più a cunversazione",
|
||||
"status.unpin": "Spuntarulà da u prufile",
|
||||
"tabs_bar.home": "Accolta",
|
||||
"tabs_bar.notifications": "Nutificazione",
|
||||
"time_remaining.days": "{number, plural, one {# ghjornu ferma} other {# ghjorni fermanu}}",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"account.enable_notifications": "Oznamovat mi příspěvky @{name}",
|
||||
"account.endorse": "Zvýraznit na profilu",
|
||||
"account.featured": "Zvýrazněné",
|
||||
"account.featured.accounts": "Profily",
|
||||
"account.featured.hashtags": "Hashtagy",
|
||||
"account.featured.posts": "Příspěvky",
|
||||
"account.featured_tags.last_status_at": "Poslední příspěvek {date}",
|
||||
|
@ -168,7 +169,7 @@
|
|||
"column.lists": "Seznamy",
|
||||
"column.mutes": "Skrytí uživatelé",
|
||||
"column.notifications": "Oznámení",
|
||||
"column.pins": "Připnuté příspěvky",
|
||||
"column.pins": "Zvýrazněné příspěvky",
|
||||
"column.public": "Federovaná časová osa",
|
||||
"column_back_button.label": "Zpět",
|
||||
"column_header.hide_settings": "Skrýt nastavení",
|
||||
|
@ -405,8 +406,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} účastník*ice} few {{counter} účastníci} other {{counter} účastníků}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
|
||||
"hashtag.counter_by_uses_today": "Dnes {count, plural, one {{counter} příspěvek} few {{counter} příspěvky} other {{counter} příspěvků}}",
|
||||
"hashtag.feature": "Zvýraznit na profilu",
|
||||
"hashtag.follow": "Sledovat hashtag",
|
||||
"hashtag.mute": "Skrýt #{hashtag}",
|
||||
"hashtag.unfeature": "Nezvýrazňovat na profilu",
|
||||
"hashtag.unfollow": "Přestat sledovat hashtag",
|
||||
"hashtags.and_other": "…a {count, plural, one {# další} few {# další} other {# dalších}}",
|
||||
"hints.profiles.followers_may_be_missing": "Sledující mohou pro tento profil chybět.",
|
||||
|
@ -477,7 +480,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Otevřít váš profil",
|
||||
"keyboard_shortcuts.notifications": "Otevřít sloupec oznámení",
|
||||
"keyboard_shortcuts.open_media": "Otevřít média",
|
||||
"keyboard_shortcuts.pinned": "Otevřít seznam připnutých příspěvků",
|
||||
"keyboard_shortcuts.pinned": "Otevřít seznam zvýrazněných příspěvků",
|
||||
"keyboard_shortcuts.profile": "Otevřít autorův profil",
|
||||
"keyboard_shortcuts.reply": "Odpovědět na příspěvek",
|
||||
"keyboard_shortcuts.requests": "Otevřít seznam žádostí o sledování",
|
||||
|
@ -561,7 +564,7 @@
|
|||
"navigation_bar.mutes": "Skrytí uživatelé",
|
||||
"navigation_bar.opened_in_classic_interface": "Příspěvky, účty a další specifické stránky jsou ve výchozím nastavení otevřeny v klasickém webovém rozhraní.",
|
||||
"navigation_bar.personal": "Osobní",
|
||||
"navigation_bar.pins": "Připnuté příspěvky",
|
||||
"navigation_bar.pins": "Zvýrazněné příspěvky",
|
||||
"navigation_bar.preferences": "Předvolby",
|
||||
"navigation_bar.public_timeline": "Federovaná časová osa",
|
||||
"navigation_bar.search": "Hledat",
|
||||
|
@ -857,8 +860,7 @@
|
|||
"status.mute": "Skrýt @{name}",
|
||||
"status.mute_conversation": "Skrýt konverzaci",
|
||||
"status.open": "Rozbalit tento příspěvek",
|
||||
"status.pin": "Připnout na profil",
|
||||
"status.pinned": "Připnutý příspěvek",
|
||||
"status.pin": "Zvýraznit na profilu",
|
||||
"status.read_more": "Číst více",
|
||||
"status.reblog": "Boostnout",
|
||||
"status.reblog_private": "Boostnout s původní viditelností",
|
||||
|
@ -883,7 +885,7 @@
|
|||
"status.translated_from_with": "Přeloženo z {lang} pomocí {provider}",
|
||||
"status.uncached_media_warning": "Náhled není k dispozici",
|
||||
"status.unmute_conversation": "Zrušit skrytí konverzace",
|
||||
"status.unpin": "Odepnout z profilu",
|
||||
"status.unpin": "Nezvýrazňovat na profilu",
|
||||
"subscribed_languages.lead": "Ve vašem domovském kanálu a časových osách se po změně budou objevovat pouze příspěvky ve vybraných jazycích. Pro příjem příspěvků ve všech jazycích nevyberte žádný jazyk.",
|
||||
"subscribed_languages.save": "Uložit změny",
|
||||
"subscribed_languages.target": "Změnit odebírané jazyky na {target}",
|
||||
|
|
|
@ -6,19 +6,19 @@
|
|||
"about.domain_blocks.preamble": "Fel rheol, mae Mastodon yn caniatáu i chi weld cynnwys gan unrhyw weinyddwr arall yn y ffedysawd a rhyngweithio â hi. Dyma'r eithriadau a wnaed ar y gweinydd penodol hwn.",
|
||||
"about.domain_blocks.silenced.explanation": "Fel rheol, fyddwch chi ddim yn gweld proffiliau a chynnwys o'r gweinydd hwn, oni bai eich bod yn chwilio'n benodol amdano neu yn ymuno drwy ei ddilyn.",
|
||||
"about.domain_blocks.silenced.title": "Cyfyngedig",
|
||||
"about.domain_blocks.suspended.explanation": "Ni fydd data o'r gweinydd hwn yn cael ei brosesu, ei gadw na'i gyfnewid, gan wneud unrhyw ryngweithio neu gyfathrebu gyda defnyddwyr o'r gweinydd hwn yn amhosibl.",
|
||||
"about.domain_blocks.suspended.explanation": "Fydd data o'r gweinydd hwn ddim yn cael ei brosesu, ei gadw na'i gyfnewid, gan wneud unrhyw ryngweithio neu gyfathrebu gyda defnyddwyr o'r gweinydd hwn yn amhosibl.",
|
||||
"about.domain_blocks.suspended.title": "Wedi'i atal",
|
||||
"about.not_available": "Nid yw'r wybodaeth hon ar gael ar y gweinydd hwn.",
|
||||
"about.not_available": "Dyw'r wybodaeth yma heb ei wneud ar gael ar y gweinydd hwn.",
|
||||
"about.powered_by": "Cyfrwng cymdeithasol datganoledig wedi ei yrru gan {mastodon}",
|
||||
"about.rules": "Rheolau'r gweinydd",
|
||||
"account.account_note_header": "Nodyn personol",
|
||||
"account.add_or_remove_from_list": "Ychwanegu neu Ddileu o'r rhestrau",
|
||||
"account.badges.bot": "Awtomataidd",
|
||||
"account.badges.group": "Grŵp",
|
||||
"account.block": "Blocio @{name}",
|
||||
"account.block_domain": "Blocio'r parth {domain}",
|
||||
"account.block_short": "Blocio",
|
||||
"account.blocked": "Blociwyd",
|
||||
"account.block": "Rhwystro @{name}",
|
||||
"account.block_domain": "Rhwystro'r parth {domain}",
|
||||
"account.block_short": "Rhwystro",
|
||||
"account.blocked": "Wedi'i rwystro",
|
||||
"account.blocking": "Yn Rhwystro",
|
||||
"account.cancel_follow_request": "Tynnu cais i ddilyn",
|
||||
"account.copy": "Copïo dolen i'r proffil",
|
||||
|
@ -28,7 +28,7 @@
|
|||
"account.edit_profile": "Golygu'r proffil",
|
||||
"account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio",
|
||||
"account.endorse": "Dangos ar fy mhroffil",
|
||||
"account.featured": "Dethol",
|
||||
"account.featured": "Nodwedd",
|
||||
"account.featured.hashtags": "Hashnodau",
|
||||
"account.featured.posts": "Postiadau",
|
||||
"account.featured_tags.last_status_at": "Y postiad olaf ar {date}",
|
||||
|
@ -40,7 +40,7 @@
|
|||
"account.followers_counter": "{count, plural, one {{counter} dilynwr} two {{counter} ddilynwr} other {{counter} dilynwyr}}",
|
||||
"account.following": "Yn dilyn",
|
||||
"account.following_counter": "{count, plural, one {Yn dilyn {counter}} other {Yn dilyn {counter} arall}}",
|
||||
"account.follows.empty": "Nid yw'r defnyddiwr hwn yn dilyn unrhyw un eto.",
|
||||
"account.follows.empty": "Dyw'r defnyddiwr hwn ddim yn dilyn unrhyw un eto.",
|
||||
"account.follows_you": "Yn eich dilyn chi",
|
||||
"account.go_to_profile": "Mynd i'r proffil",
|
||||
"account.hide_reblogs": "Cuddio hybiau gan @{name}",
|
||||
|
@ -168,7 +168,7 @@
|
|||
"column.lists": "Rhestrau",
|
||||
"column.mutes": "Defnyddwyr wedi'u tewi",
|
||||
"column.notifications": "Hysbysiadau",
|
||||
"column.pins": "Postiadau wedi eu pinio",
|
||||
"column.pins": "Postiadau nodwedd",
|
||||
"column.public": "Ffrwd y ffederasiwn",
|
||||
"column_back_button.label": "Nôl",
|
||||
"column_header.hide_settings": "Cuddio'r dewisiadau",
|
||||
|
@ -477,7 +477,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Agor eich proffil",
|
||||
"keyboard_shortcuts.notifications": "Agor colofn hysbysiadau",
|
||||
"keyboard_shortcuts.open_media": "Agor cyfryngau",
|
||||
"keyboard_shortcuts.pinned": "Agor rhestr postiadau wedi'u pinio",
|
||||
"keyboard_shortcuts.pinned": "Agor rhestr postiadau nodwedd",
|
||||
"keyboard_shortcuts.profile": "Agor proffil yr awdur",
|
||||
"keyboard_shortcuts.reply": "Ymateb i bostiad",
|
||||
"keyboard_shortcuts.requests": "Agor rhestr ceisiadau dilyn",
|
||||
|
@ -561,7 +561,7 @@
|
|||
"navigation_bar.mutes": "Defnyddwyr wedi'u tewi",
|
||||
"navigation_bar.opened_in_classic_interface": "Mae postiadau, cyfrifon a thudalennau penodol eraill yn cael eu hagor fel rhagosodiad yn y rhyngwyneb gwe clasurol.",
|
||||
"navigation_bar.personal": "Personol",
|
||||
"navigation_bar.pins": "Postiadau wedi eu pinio",
|
||||
"navigation_bar.pins": "Postiadau nodwedd",
|
||||
"navigation_bar.preferences": "Dewisiadau",
|
||||
"navigation_bar.public_timeline": "Ffrwd y ffederasiwn",
|
||||
"navigation_bar.search": "Chwilio",
|
||||
|
@ -857,8 +857,7 @@
|
|||
"status.mute": "Anwybyddu @{name}",
|
||||
"status.mute_conversation": "Anwybyddu sgwrs",
|
||||
"status.open": "Ehangu'r post hwn",
|
||||
"status.pin": "Pinio ar y proffil",
|
||||
"status.pinned": "Postiad wedi'i binio",
|
||||
"status.pin": "Dangos ar y proffil",
|
||||
"status.read_more": "Darllen rhagor",
|
||||
"status.reblog": "Hybu",
|
||||
"status.reblog_private": "Hybu i'r gynulleidfa wreiddiol",
|
||||
|
@ -883,7 +882,7 @@
|
|||
"status.translated_from_with": "Cyfieithwyd o {lang} gan ddefnyddio {provider}",
|
||||
"status.uncached_media_warning": "Dim rhagolwg ar gael",
|
||||
"status.unmute_conversation": "Dad-dewi sgwrs",
|
||||
"status.unpin": "Dadbinio o'r proffil",
|
||||
"status.unpin": "Peidio a'i ddangos ar fy mhroffil",
|
||||
"subscribed_languages.lead": "Dim ond postiadau mewn ieithoedd penodol fydd yn ymddangos yn eich ffrydiau cartref a rhestr ar ôl y newid. Dewiswch ddim byd i dderbyn postiadau ym mhob iaith.",
|
||||
"subscribed_languages.save": "Cadw'r newidiadau",
|
||||
"subscribed_languages.target": "Newid ieithoedd tanysgrifio {target}",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"account.enable_notifications": "Advisér mig, når @{name} poster",
|
||||
"account.endorse": "Fremhæv på profil",
|
||||
"account.featured": "Fremhævet",
|
||||
"account.featured.accounts": "Profiler",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Indlæg",
|
||||
"account.featured_tags.last_status_at": "Seneste indlæg {date}",
|
||||
|
@ -168,7 +169,7 @@
|
|||
"column.lists": "Lister",
|
||||
"column.mutes": "Skjulte brugere",
|
||||
"column.notifications": "Notifikationer",
|
||||
"column.pins": "Fastgjorte indlæg",
|
||||
"column.pins": "Fremhævede indlæg",
|
||||
"column.public": "Fælles tidslinje",
|
||||
"column_back_button.label": "Tilbage",
|
||||
"column_header.hide_settings": "Skjul indstillinger",
|
||||
|
@ -405,8 +406,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} deltager} other {{counter} deltagere}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} indlæg} other {{counter} indlæg}} i dag",
|
||||
"hashtag.feature": "Fremhæv på profil",
|
||||
"hashtag.follow": "Følg etiket",
|
||||
"hashtag.mute": "Tavsgør #{hashtag}",
|
||||
"hashtag.unfeature": "Fremhæv ikke på profil",
|
||||
"hashtag.unfollow": "Stop med at følge etiket",
|
||||
"hashtags.and_other": "…og {count, plural, one {}other {# flere}}",
|
||||
"hints.profiles.followers_may_be_missing": "Der kan mangle følgere for denne profil.",
|
||||
|
@ -477,7 +480,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Åbn din profil",
|
||||
"keyboard_shortcuts.notifications": "for at åbne notifikationskolonnen",
|
||||
"keyboard_shortcuts.open_media": "Åbn medier",
|
||||
"keyboard_shortcuts.pinned": "Åbn liste over fastgjorte indlæg",
|
||||
"keyboard_shortcuts.pinned": "Åbn liste over fremhævede indlæg",
|
||||
"keyboard_shortcuts.profile": "Åbn forfatters profil",
|
||||
"keyboard_shortcuts.reply": "Besvar indlægget",
|
||||
"keyboard_shortcuts.requests": "Åbn liste over følgeanmodninger",
|
||||
|
@ -561,7 +564,7 @@
|
|||
"navigation_bar.mutes": "Skjulte brugere",
|
||||
"navigation_bar.opened_in_classic_interface": "Indlæg, konti og visse andre sider åbnes som standard i den klassiske webgrænseflade.",
|
||||
"navigation_bar.personal": "Personlig",
|
||||
"navigation_bar.pins": "Fastgjorte indlæg",
|
||||
"navigation_bar.pins": "Fremhævede indlæg",
|
||||
"navigation_bar.preferences": "Præferencer",
|
||||
"navigation_bar.public_timeline": "Fælles tidslinje",
|
||||
"navigation_bar.search": "Søg",
|
||||
|
@ -857,8 +860,7 @@
|
|||
"status.mute": "Skjul @{name}",
|
||||
"status.mute_conversation": "Skjul samtale",
|
||||
"status.open": "Udvid dette indlæg",
|
||||
"status.pin": "Fastgør til profil",
|
||||
"status.pinned": "Fastgjort indlæg",
|
||||
"status.pin": "Fremhæv på profil",
|
||||
"status.read_more": "Læs mere",
|
||||
"status.reblog": "Fremhæv",
|
||||
"status.reblog_private": "Fremhæv med oprindelig synlighed",
|
||||
|
@ -883,7 +885,7 @@
|
|||
"status.translated_from_with": "Oversat fra {lang} ved brug af {provider}",
|
||||
"status.uncached_media_warning": "Ingen forhåndsvisning",
|
||||
"status.unmute_conversation": "Genaktivér samtale",
|
||||
"status.unpin": "Frigør fra profil",
|
||||
"status.unpin": "Fremhæv ikke på profil",
|
||||
"subscribed_languages.lead": "Kun indlæg på udvalgte sprog vil fremgå på dine hjemme- og listetidslinjer efter ændringen. Vælg ingen for at modtage indlæg på alle sprog.",
|
||||
"subscribed_languages.save": "Gem ændringer",
|
||||
"subscribed_languages.target": "Skift abonnementssprog for {target}",
|
||||
|
|
|
@ -27,8 +27,9 @@
|
|||
"account.domain_blocking": "Domain blockiert",
|
||||
"account.edit_profile": "Profil bearbeiten",
|
||||
"account.enable_notifications": "Benachrichtige mich wenn @{name} etwas postet",
|
||||
"account.endorse": "Im Profil empfehlen",
|
||||
"account.endorse": "Im Profil vorstellen",
|
||||
"account.featured": "Vorgestellt",
|
||||
"account.featured.accounts": "Profile",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Beiträge",
|
||||
"account.featured_tags.last_status_at": "Letzter Beitrag am {date}",
|
||||
|
@ -74,7 +75,7 @@
|
|||
"account.unblock_domain": "Blockierung von {domain} aufheben",
|
||||
"account.unblock_domain_short": "Entsperren",
|
||||
"account.unblock_short": "Blockierung aufheben",
|
||||
"account.unendorse": "Im Profil nicht mehr empfehlen",
|
||||
"account.unendorse": "Im Profil nicht mehr vorstellen",
|
||||
"account.unfollow": "Entfolgen",
|
||||
"account.unmute": "Stummschaltung von @{name} aufheben",
|
||||
"account.unmute_notifications_short": "Stummschaltung der Benachrichtigungen aufheben",
|
||||
|
@ -168,7 +169,7 @@
|
|||
"column.lists": "Listen",
|
||||
"column.mutes": "Stummgeschaltete Profile",
|
||||
"column.notifications": "Benachrichtigungen",
|
||||
"column.pins": "Angeheftete Beiträge",
|
||||
"column.pins": "Vorgestellte Beiträge",
|
||||
"column.public": "Föderierte Timeline",
|
||||
"column_back_button.label": "Zurück",
|
||||
"column_header.hide_settings": "Einstellungen ausblenden",
|
||||
|
@ -405,8 +406,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one{{counter} Beteiligte*r} other{{counter} Beteiligte}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} Beitrag} other {{counter} Beiträge}} heute",
|
||||
"hashtag.feature": "Im Profil vorstellen",
|
||||
"hashtag.follow": "Hashtag folgen",
|
||||
"hashtag.mute": "#{hashtag} stummschalten",
|
||||
"hashtag.unfeature": "Im Profil nicht mehr vorstellen",
|
||||
"hashtag.unfollow": "Hashtag entfolgen",
|
||||
"hashtags.and_other": "… und {count, plural, one{# weiterer} other {# weitere}}",
|
||||
"hints.profiles.followers_may_be_missing": "Möglicherweise werden für dieses Profil nicht alle Follower angezeigt.",
|
||||
|
@ -426,7 +429,7 @@
|
|||
"home.show_announcements": "Ankündigungen anzeigen",
|
||||
"ignore_notifications_modal.disclaimer": "Mastodon kann anderen Nutzer*innen nicht mitteilen, dass du deren Benachrichtigungen ignorierst. Das Ignorieren von Benachrichtigungen wird nicht das Absenden der Nachricht selbst unterbinden.",
|
||||
"ignore_notifications_modal.filter_instead": "Stattdessen filtern",
|
||||
"ignore_notifications_modal.filter_to_act_users": "Du wirst weiterhin die Möglichkeit haben, andere Nutzer*innen zu genehmigen, abzulehnen oder zu melden",
|
||||
"ignore_notifications_modal.filter_to_act_users": "Du wirst weiterhin die Möglichkeit haben, andere Nutzer*innen zu akzeptieren, abzulehnen oder zu melden",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "Filtern hilft, mögliches Durcheinander zu vermeiden",
|
||||
"ignore_notifications_modal.filter_to_review_separately": "Gefilterte Benachrichtigungen können separat überprüft werden",
|
||||
"ignore_notifications_modal.ignore": "Benachrichtigungen ignorieren",
|
||||
|
@ -477,7 +480,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Eigenes Profil aufrufen",
|
||||
"keyboard_shortcuts.notifications": "Benachrichtigungen aufrufen",
|
||||
"keyboard_shortcuts.open_media": "Medieninhalt öffnen",
|
||||
"keyboard_shortcuts.pinned": "Liste angehefteter Beiträge öffnen",
|
||||
"keyboard_shortcuts.pinned": "Liste vorgestellter Beiträge öffnen",
|
||||
"keyboard_shortcuts.profile": "Profil aufrufen",
|
||||
"keyboard_shortcuts.reply": "Auf Beitrag antworten",
|
||||
"keyboard_shortcuts.requests": "Liste der Follower-Anfragen aufrufen",
|
||||
|
@ -561,7 +564,7 @@
|
|||
"navigation_bar.mutes": "Stummgeschaltete Profile",
|
||||
"navigation_bar.opened_in_classic_interface": "Beiträge, Konten und andere bestimmte Seiten werden standardmäßig im klassischen Webinterface geöffnet.",
|
||||
"navigation_bar.personal": "Persönlich",
|
||||
"navigation_bar.pins": "Angeheftete Beiträge",
|
||||
"navigation_bar.pins": "Vorgestellte Beiträge",
|
||||
"navigation_bar.preferences": "Einstellungen",
|
||||
"navigation_bar.public_timeline": "Föderierte Timeline",
|
||||
"navigation_bar.search": "Suche",
|
||||
|
@ -610,11 +613,11 @@
|
|||
"notification.relationships_severance_event.user_domain_block": "Du hast {target} blockiert – {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst, wurden entfernt.",
|
||||
"notification.status": "{name} postete …",
|
||||
"notification.update": "{name} bearbeitete einen Beitrag",
|
||||
"notification_requests.accept": "Genehmigen",
|
||||
"notification_requests.accept_multiple": "{count, plural, one {# Anfrage genehmigen …} other {# Anfragen genehmigen …}}",
|
||||
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Anfrage genehmigen} other {Anfragen genehmigen}}",
|
||||
"notification_requests.confirm_accept_multiple.message": "Du bist dabei, {{count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} zu genehmigen. Möchtest du wirklich fortfahren?",
|
||||
"notification_requests.confirm_accept_multiple.title": "Benachrichtigungsanfragen genehmigen?",
|
||||
"notification_requests.accept": "Akzeptieren",
|
||||
"notification_requests.accept_multiple": "{count, plural, one {# Anfrage akzeptieren …} other {# Anfragen akzeptieren …}}",
|
||||
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Anfrage akzeptieren} other {Anfragen akzeptieren}}",
|
||||
"notification_requests.confirm_accept_multiple.message": "Du bist dabei, {{count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} zu akzeptieren. Möchtest du wirklich fortfahren?",
|
||||
"notification_requests.confirm_accept_multiple.title": "Benachrichtigungsanfragen akzeptieren?",
|
||||
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Anfrage ablehnen} other {Anfragen ablehnen}}",
|
||||
"notification_requests.confirm_dismiss_multiple.message": "Du bist dabei, {count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} abzulehnen. Du wirst nicht mehr ohne Weiteres auf {count, plural, one {sie} other {sie}} zugreifen können. Möchtest du wirklich fortfahren?",
|
||||
"notification_requests.confirm_dismiss_multiple.title": "Benachrichtigungsanfragen ablehnen?",
|
||||
|
@ -857,8 +860,7 @@
|
|||
"status.mute": "@{name} stummschalten",
|
||||
"status.mute_conversation": "Unterhaltung stummschalten",
|
||||
"status.open": "Beitrag öffnen",
|
||||
"status.pin": "Im Profil anheften",
|
||||
"status.pinned": "Angehefteter Beitrag",
|
||||
"status.pin": "Im Profil vorstellen",
|
||||
"status.read_more": "Gesamten Beitrag anschauen",
|
||||
"status.reblog": "Teilen",
|
||||
"status.reblog_private": "Mit der ursprünglichen Zielgruppe teilen",
|
||||
|
@ -883,7 +885,7 @@
|
|||
"status.translated_from_with": "Aus {lang} mittels {provider} übersetzt",
|
||||
"status.uncached_media_warning": "Vorschau nicht verfügbar",
|
||||
"status.unmute_conversation": "Stummschaltung der Unterhaltung aufheben",
|
||||
"status.unpin": "Vom Profil lösen",
|
||||
"status.unpin": "Im Profil nicht mehr vorstellen",
|
||||
"subscribed_languages.lead": "Nach der Änderung werden nur noch Beiträge in den ausgewählten Sprachen in den Timelines deiner Startseite und deiner Listen angezeigt. Wähle keine Sprache aus, um alle Beiträge zu sehen.",
|
||||
"subscribed_languages.save": "Änderungen speichern",
|
||||
"subscribed_languages.target": "Abonnierte Sprachen für {target} ändern",
|
||||
|
|
|
@ -161,7 +161,6 @@
|
|||
"column.lists": "Λίστες",
|
||||
"column.mutes": "Αποσιωπημένοι χρήστες",
|
||||
"column.notifications": "Ειδοποιήσεις",
|
||||
"column.pins": "Καρφιτσωμένα τουτ",
|
||||
"column.public": "Ομοσπονδιακή ροή",
|
||||
"column_back_button.label": "Πίσω",
|
||||
"column_header.hide_settings": "Απόκρυψη ρυθμίσεων",
|
||||
|
@ -464,7 +463,6 @@
|
|||
"keyboard_shortcuts.my_profile": "Άνοιγμα του προφίλ σου",
|
||||
"keyboard_shortcuts.notifications": "Άνοιγμα στήλης ειδοποιήσεων",
|
||||
"keyboard_shortcuts.open_media": "Άνοιγμα πολυμέσων",
|
||||
"keyboard_shortcuts.pinned": "Άνοιγμα λίστας καρφιτσωμένων αναρτήσεων",
|
||||
"keyboard_shortcuts.profile": "Άνοιγμα προφίλ συγγραφέα",
|
||||
"keyboard_shortcuts.reply": "Απάντηση στην ανάρτηση",
|
||||
"keyboard_shortcuts.requests": "Άνοιγμα λίστας αιτημάτων ακολούθησης",
|
||||
|
@ -548,7 +546,6 @@
|
|||
"navigation_bar.mutes": "Αποσιωπημένοι χρήστες",
|
||||
"navigation_bar.opened_in_classic_interface": "Δημοσιεύσεις, λογαριασμοί και άλλες συγκεκριμένες σελίδες ανοίγονται από προεπιλογή στην κλασική διεπαφή ιστού.",
|
||||
"navigation_bar.personal": "Προσωπικά",
|
||||
"navigation_bar.pins": "Καρφιτσωμένες αναρτήσεις",
|
||||
"navigation_bar.preferences": "Προτιμήσεις",
|
||||
"navigation_bar.public_timeline": "Ροή συναλλαγών",
|
||||
"navigation_bar.search": "Αναζήτηση",
|
||||
|
@ -844,8 +841,6 @@
|
|||
"status.mute": "Σίγαση σε @{name}",
|
||||
"status.mute_conversation": "Σίγαση συνομιλίας",
|
||||
"status.open": "Επέκταση ανάρτησης",
|
||||
"status.pin": "Καρφίτσωσε στο προφίλ",
|
||||
"status.pinned": "Καρφιτσωμένη ανάρτηση",
|
||||
"status.read_more": "Διάβασε περισότερα",
|
||||
"status.reblog": "Ενίσχυση",
|
||||
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",
|
||||
|
@ -870,7 +865,6 @@
|
|||
"status.translated_from_with": "Μεταφράστηκε από {lang} χρησιμοποιώντας {provider}",
|
||||
"status.uncached_media_warning": "Μη διαθέσιμη προεπισκόπηση",
|
||||
"status.unmute_conversation": "Αναίρεση σίγασης συνομιλίας",
|
||||
"status.unpin": "Ξεκαρφίτσωσε από το προφίλ",
|
||||
"subscribed_languages.lead": "Μόνο αναρτήσεις σε επιλεγμένες γλώσσες θα εμφανίζονται στην αρχική σου και θα παραθέτονται χρονοδιαγράμματα μετά την αλλαγή. Επέλεξε καμία για να λαμβάνεις αναρτήσεις σε όλες τις γλώσσες.",
|
||||
"subscribed_languages.save": "Αποθήκευση αλλαγών",
|
||||
"subscribed_languages.target": "Αλλαγή εγγεγραμμένων γλωσσών για {target}",
|
||||
|
|
|
@ -157,7 +157,6 @@
|
|||
"column.lists": "Lists",
|
||||
"column.mutes": "Muted users",
|
||||
"column.notifications": "Notifications",
|
||||
"column.pins": "Pinned posts",
|
||||
"column.public": "Federated timeline",
|
||||
"column_back_button.label": "Back",
|
||||
"column_header.hide_settings": "Hide settings",
|
||||
|
@ -457,7 +456,6 @@
|
|||
"keyboard_shortcuts.my_profile": "to open your profile",
|
||||
"keyboard_shortcuts.notifications": "Open notifications column",
|
||||
"keyboard_shortcuts.open_media": "to open media",
|
||||
"keyboard_shortcuts.pinned": "to open pinned posts list",
|
||||
"keyboard_shortcuts.profile": "to open author's profile",
|
||||
"keyboard_shortcuts.reply": "to reply",
|
||||
"keyboard_shortcuts.requests": "to open follow requests list",
|
||||
|
@ -541,7 +539,6 @@
|
|||
"navigation_bar.mutes": "Muted users",
|
||||
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Pinned posts",
|
||||
"navigation_bar.preferences": "Preferences",
|
||||
"navigation_bar.public_timeline": "Federated timeline",
|
||||
"navigation_bar.search": "Search",
|
||||
|
@ -837,8 +834,6 @@
|
|||
"status.mute": "Mute @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.open": "Expand this post",
|
||||
"status.pin": "Pin on profile",
|
||||
"status.pinned": "Pinned post",
|
||||
"status.read_more": "Read more",
|
||||
"status.reblog": "Boost",
|
||||
"status.reblog_private": "Boost with original visibility",
|
||||
|
@ -863,7 +858,6 @@
|
|||
"status.translated_from_with": "Translated from {lang} using {provider}",
|
||||
"status.uncached_media_warning": "Preview not available",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unpin": "Unpin from profile",
|
||||
"subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.",
|
||||
"subscribed_languages.save": "Save changes",
|
||||
"subscribed_languages.target": "Change subscribed languages for {target}",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"account.enable_notifications": "Notify me when @{name} posts",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.featured": "Featured",
|
||||
"account.featured.accounts": "Profiles",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Posts",
|
||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||
|
@ -168,7 +169,7 @@
|
|||
"column.lists": "Lists",
|
||||
"column.mutes": "Muted users",
|
||||
"column.notifications": "Notifications",
|
||||
"column.pins": "Pinned posts",
|
||||
"column.pins": "Featured posts",
|
||||
"column.public": "Federated timeline",
|
||||
"column_back_button.label": "Back",
|
||||
"column_header.hide_settings": "Hide settings",
|
||||
|
@ -405,8 +406,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participant} other {{counter} participants}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} posts}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
|
||||
"hashtag.feature": "Feature on profile",
|
||||
"hashtag.follow": "Follow hashtag",
|
||||
"hashtag.mute": "Mute #{hashtag}",
|
||||
"hashtag.unfeature": "Don't feature on profile",
|
||||
"hashtag.unfollow": "Unfollow hashtag",
|
||||
"hashtags.and_other": "…and {count, plural, other {# more}}",
|
||||
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",
|
||||
|
@ -477,7 +480,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Open your profile",
|
||||
"keyboard_shortcuts.notifications": "Open notifications column",
|
||||
"keyboard_shortcuts.open_media": "Open media",
|
||||
"keyboard_shortcuts.pinned": "Open pinned posts list",
|
||||
"keyboard_shortcuts.pinned": "Open featured posts list",
|
||||
"keyboard_shortcuts.profile": "Open author's profile",
|
||||
"keyboard_shortcuts.reply": "Reply to post",
|
||||
"keyboard_shortcuts.requests": "Open follow requests list",
|
||||
|
@ -561,7 +564,7 @@
|
|||
"navigation_bar.mutes": "Muted users",
|
||||
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Pinned posts",
|
||||
"navigation_bar.pins": "Featured posts",
|
||||
"navigation_bar.preferences": "Preferences",
|
||||
"navigation_bar.public_timeline": "Federated timeline",
|
||||
"navigation_bar.search": "Search",
|
||||
|
@ -857,8 +860,7 @@
|
|||
"status.mute": "Mute @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.open": "Expand this post",
|
||||
"status.pin": "Pin on profile",
|
||||
"status.pinned": "Pinned post",
|
||||
"status.pin": "Feature on profile",
|
||||
"status.read_more": "Read more",
|
||||
"status.reblog": "Boost",
|
||||
"status.reblog_private": "Boost with original visibility",
|
||||
|
@ -883,7 +885,7 @@
|
|||
"status.translated_from_with": "Translated from {lang} using {provider}",
|
||||
"status.uncached_media_warning": "Preview not available",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unpin": "Unpin from profile",
|
||||
"status.unpin": "Don't feature on profile",
|
||||
"subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.",
|
||||
"subscribed_languages.save": "Save changes",
|
||||
"subscribed_languages.target": "Change subscribed languages for {target}",
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
"account.disable_notifications": "Ĉesu sciigi min kiam @{name} afiŝas",
|
||||
"account.edit_profile": "Redakti la profilon",
|
||||
"account.enable_notifications": "Sciigu min kiam @{name} afiŝos",
|
||||
"account.endorse": "Prezenti ĉe via profilo",
|
||||
"account.endorse": "Montri en profilo",
|
||||
"account.featured.hashtags": "Kradvortoj",
|
||||
"account.featured.posts": "Afiŝoj",
|
||||
"account.featured_tags.last_status_at": "Lasta afîŝo je {date}",
|
||||
|
@ -162,7 +162,6 @@
|
|||
"column.lists": "Listoj",
|
||||
"column.mutes": "Silentigitaj uzantoj",
|
||||
"column.notifications": "Sciigoj",
|
||||
"column.pins": "Alpinglitaj afiŝoj",
|
||||
"column.public": "Fratara templinio",
|
||||
"column_back_button.label": "Reveni",
|
||||
"column_header.hide_settings": "Kaŝi la agordojn",
|
||||
|
@ -462,7 +461,6 @@
|
|||
"keyboard_shortcuts.my_profile": "Malfermu vian profilon",
|
||||
"keyboard_shortcuts.notifications": "Malfermu la sciigajn kolumnon",
|
||||
"keyboard_shortcuts.open_media": "Malfermi vidaŭdaĵon",
|
||||
"keyboard_shortcuts.pinned": "Malfermu alpinglitajn afiŝojn-liston",
|
||||
"keyboard_shortcuts.profile": "Malfermu la profilon de aŭtoroprofilo",
|
||||
"keyboard_shortcuts.reply": "Respondu al afiŝo",
|
||||
"keyboard_shortcuts.requests": "Malfermi la liston de petoj por sekvado",
|
||||
|
@ -546,7 +544,6 @@
|
|||
"navigation_bar.mutes": "Silentigitaj uzantoj",
|
||||
"navigation_bar.opened_in_classic_interface": "Afiŝoj, kontoj, kaj aliaj specifaj paĝoj kiuj estas malfermititaj defaulta en la klasika reta interfaco.",
|
||||
"navigation_bar.personal": "Persone",
|
||||
"navigation_bar.pins": "Alpinglitaj afiŝoj",
|
||||
"navigation_bar.preferences": "Preferoj",
|
||||
"navigation_bar.public_timeline": "Fratara templinio",
|
||||
"navigation_bar.search": "Serĉi",
|
||||
|
@ -842,8 +839,6 @@
|
|||
"status.mute": "Silentigi @{name}",
|
||||
"status.mute_conversation": "Silentigi konversacion",
|
||||
"status.open": "Pligrandigu ĉi tiun afiŝon",
|
||||
"status.pin": "Alpingli al la profilo",
|
||||
"status.pinned": "Alpinglita afiŝo",
|
||||
"status.read_more": "Legi pli",
|
||||
"status.reblog": "Diskonigi",
|
||||
"status.reblog_private": "Diskonigi kun la sama videbleco",
|
||||
|
@ -868,7 +863,6 @@
|
|||
"status.translated_from_with": "Tradukita el {lang} per {provider}",
|
||||
"status.uncached_media_warning": "Antaŭrigardo ne disponebla",
|
||||
"status.unmute_conversation": "Malsilentigi la konversacion",
|
||||
"status.unpin": "Depingli de profilo",
|
||||
"subscribed_languages.lead": "Nur afiŝoj en elektitaj lingvoj aperos en viaj hejma kaj lista templinioj post la ŝanĝo. Elektu nenion por ricevi afiŝojn en ĉiuj lingvoj.",
|
||||
"subscribed_languages.save": "Konservi ŝanĝojn",
|
||||
"subscribed_languages.target": "Ŝanĝu abonitajn lingvojn por {target}",
|
||||
|
|
|
@ -24,11 +24,12 @@
|
|||
"account.copy": "Copiar enlace al perfil",
|
||||
"account.direct": "Mención privada a @{name}",
|
||||
"account.disable_notifications": "Dejar de notificarme cuando @{name} envíe mensajes",
|
||||
"account.domain_blocking": "Bloqueando dominio",
|
||||
"account.domain_blocking": "Dominio bloqueado",
|
||||
"account.edit_profile": "Editar perfil",
|
||||
"account.enable_notifications": "Notificarme cuando @{name} envíe mensajes",
|
||||
"account.endorse": "Destacar en el perfil",
|
||||
"account.featured": "Destacados",
|
||||
"account.featured.accounts": "Perfiles",
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured.posts": "Mensajes",
|
||||
"account.featured_tags.last_status_at": "Último mensaje: {date}",
|
||||
|
@ -168,7 +169,7 @@
|
|||
"column.lists": "Listas",
|
||||
"column.mutes": "Usuarios silenciados",
|
||||
"column.notifications": "Notificaciones",
|
||||
"column.pins": "Mensajes fijados",
|
||||
"column.pins": "Mensajes destacados",
|
||||
"column.public": "Línea temporal federada",
|
||||
"column_back_button.label": "Volver",
|
||||
"column_header.hide_settings": "Ocultar configuración",
|
||||
|
@ -405,8 +406,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} mensaje} other {{counter} mensajes}} hoy",
|
||||
"hashtag.feature": "Destacar en el perfil",
|
||||
"hashtag.follow": "Seguir etiqueta",
|
||||
"hashtag.mute": "Silenciar #{hashtag}",
|
||||
"hashtag.unfeature": "No destacar en el perfil",
|
||||
"hashtag.unfollow": "Dejar de seguir etiqueta",
|
||||
"hashtags.and_other": "…y {count, plural, other {# más}}",
|
||||
"hints.profiles.followers_may_be_missing": "Es posible que falten seguidores de este perfil.",
|
||||
|
@ -477,7 +480,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Abrir tu perfil",
|
||||
"keyboard_shortcuts.notifications": "Abrir columna de notificaciones",
|
||||
"keyboard_shortcuts.open_media": "Abrir archivos de medios",
|
||||
"keyboard_shortcuts.pinned": "Abrir lista de mensajes fijados",
|
||||
"keyboard_shortcuts.pinned": "Abrir lista de mensajes destacados",
|
||||
"keyboard_shortcuts.profile": "Abrir perfil del autor",
|
||||
"keyboard_shortcuts.reply": "Responder al mensaje",
|
||||
"keyboard_shortcuts.requests": "Abrir lista de solicitudes de seguimiento",
|
||||
|
@ -561,7 +564,7 @@
|
|||
"navigation_bar.mutes": "Usuarios silenciados",
|
||||
"navigation_bar.opened_in_classic_interface": "Los mensajes, las cuentas y otras páginas específicas se abren predeterminadamente en la interface web clásica.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Mensajes fijados",
|
||||
"navigation_bar.pins": "Mensajes destacados",
|
||||
"navigation_bar.preferences": "Configuración",
|
||||
"navigation_bar.public_timeline": "Línea temporal federada",
|
||||
"navigation_bar.search": "Buscar",
|
||||
|
@ -857,8 +860,7 @@
|
|||
"status.mute": "Silenciar a @{name}",
|
||||
"status.mute_conversation": "Silenciar conversación",
|
||||
"status.open": "Expandir este mensaje",
|
||||
"status.pin": "Fijar en el perfil",
|
||||
"status.pinned": "Mensaje fijado",
|
||||
"status.pin": "Destacar en el perfil",
|
||||
"status.read_more": "Leé más",
|
||||
"status.reblog": "Adherir",
|
||||
"status.reblog_private": "Adherir a la audiencia original",
|
||||
|
@ -883,7 +885,7 @@
|
|||
"status.translated_from_with": "Traducido desde el {lang} vía {provider}",
|
||||
"status.uncached_media_warning": "Previsualización no disponible",
|
||||
"status.unmute_conversation": "Dejar de silenciar conversación",
|
||||
"status.unpin": "Dejar de fijar",
|
||||
"status.unpin": "No destacar en el perfil",
|
||||
"subscribed_languages.lead": "Después del cambio, sólo los mensajes en los idiomas seleccionados aparecerán en tu línea temporal Principal y en las líneas de tiempo de lista. No seleccionés ningún idioma para poder recibir mensajes en todos los idiomas.",
|
||||
"subscribed_languages.save": "Guardar cambios",
|
||||
"subscribed_languages.target": "Cambiar idiomas suscritos para {target}",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"account.enable_notifications": "Notificarme cuando @{name} publique algo",
|
||||
"account.endorse": "Destacar en mi perfil",
|
||||
"account.featured": "Destacado",
|
||||
"account.featured.accounts": "Perfiles",
|
||||
"account.featured.hashtags": "Etiquetas",
|
||||
"account.featured.posts": "Publicaciones",
|
||||
"account.featured_tags.last_status_at": "Última publicación el {date}",
|
||||
|
@ -168,7 +169,7 @@
|
|||
"column.lists": "Listas",
|
||||
"column.mutes": "Usuarios silenciados",
|
||||
"column.notifications": "Notificaciones",
|
||||
"column.pins": "Publicaciones fijadas",
|
||||
"column.pins": "Publicaciones destacadas",
|
||||
"column.public": "Línea de tiempo federada",
|
||||
"column_back_button.label": "Atrás",
|
||||
"column_header.hide_settings": "Ocultar configuración",
|
||||
|
@ -405,8 +406,10 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, one {{counter} participante} other {{counter} participantes}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}} hoy",
|
||||
"hashtag.feature": "Destacar en el perfil",
|
||||
"hashtag.follow": "Seguir etiqueta",
|
||||
"hashtag.mute": "Silenciar #{hashtag}",
|
||||
"hashtag.unfeature": "No destacar en el perfil",
|
||||
"hashtag.unfollow": "Dejar de seguir etiqueta",
|
||||
"hashtags.and_other": "…y {count, plural, other {# más}}",
|
||||
"hints.profiles.followers_may_be_missing": "Puede que no se muestren todos los seguidores de este perfil.",
|
||||
|
@ -477,7 +480,7 @@
|
|||
"keyboard_shortcuts.my_profile": "Abrir tu perfil",
|
||||
"keyboard_shortcuts.notifications": "Abrir la columna de notificaciones",
|
||||
"keyboard_shortcuts.open_media": "Abrir multimedia",
|
||||
"keyboard_shortcuts.pinned": "Abrir la lista de publicaciones fijadas",
|
||||
"keyboard_shortcuts.pinned": "Abrir lista de publicaciones destacadas",
|
||||
"keyboard_shortcuts.profile": "Abrir perfil del autor",
|
||||
"keyboard_shortcuts.reply": "Responder a la publicación",
|
||||
"keyboard_shortcuts.requests": "Abrir lista de solicitudes de seguimiento",
|
||||
|
@ -561,7 +564,7 @@
|
|||
"navigation_bar.mutes": "Usuarios silenciados",
|
||||
"navigation_bar.opened_in_classic_interface": "Publicaciones, cuentas y otras páginas específicas se abren por defecto en la interfaz web clásica.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Publicaciones fijadas",
|
||||
"navigation_bar.pins": "Publicaciones destacadas",
|
||||
"navigation_bar.preferences": "Preferencias",
|
||||
"navigation_bar.public_timeline": "Historia federada",
|
||||
"navigation_bar.search": "Buscar",
|
||||
|
@ -857,8 +860,7 @@
|
|||
"status.mute": "Silenciar @{name}",
|
||||
"status.mute_conversation": "Silenciar conversación",
|
||||
"status.open": "Expandir estado",
|
||||
"status.pin": "Fijar",
|
||||
"status.pinned": "Publicación fijada",
|
||||
"status.pin": "Destacar en el perfil",
|
||||
"status.read_more": "Leer más",
|
||||
"status.reblog": "Impulsar",
|
||||
"status.reblog_private": "Implusar a la audiencia original",
|
||||
|
@ -883,7 +885,7 @@
|
|||
"status.translated_from_with": "Traducido del {lang} usando {provider}",
|
||||
"status.uncached_media_warning": "Vista previa no disponible",
|
||||
"status.unmute_conversation": "Dejar de silenciar conversación",
|
||||
"status.unpin": "Dejar de fijar",
|
||||
"status.unpin": "No destacar en el perfil",
|
||||
"subscribed_languages.lead": "Solo las publicaciones en los idiomas seleccionados aparecerán en tu inicio y enlistará las líneas de tiempo después del cambio. Selecciona ninguno para recibir publicaciones en todos los idiomas.",
|
||||
"subscribed_languages.save": "Guardar cambios",
|
||||
"subscribed_languages.target": "Cambiar idiomas suscritos para {target}",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user