mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-29 09:39:04 +01:00
Merge upstream (#81)
This commit is contained in:
commit
09cfc079b0
213 changed files with 2714 additions and 1364 deletions
3
.babelrc
3
.babelrc
|
@ -22,7 +22,8 @@
|
||||||
{
|
{
|
||||||
"messagesDir": "./build/messages"
|
"messagesDir": "./build/messages"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"preval"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"development": {
|
"development": {
|
||||||
|
|
|
@ -31,6 +31,17 @@ PAPERCLIP_SECRET=
|
||||||
SECRET_KEY_BASE=
|
SECRET_KEY_BASE=
|
||||||
OTP_SECRET=
|
OTP_SECRET=
|
||||||
|
|
||||||
|
# VAPID keys (used for push notifications
|
||||||
|
# You can generate the keys using the following command (first is the private key, second is the public one)
|
||||||
|
# You should only generate this once per instance. If you later decide to change it, all push subscription will
|
||||||
|
# be invalidated, requiring the users to access the website again to resubscribe.
|
||||||
|
#
|
||||||
|
# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
|
||||||
|
#
|
||||||
|
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
|
||||||
# Registrations
|
# Registrations
|
||||||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
# Single user mode will disable registrations and redirect frontpage to the first profile
|
||||||
# SINGLE_USER_MODE=true
|
# SINGLE_USER_MODE=true
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -21,6 +21,7 @@ public/system
|
||||||
public/assets
|
public/assets
|
||||||
public/packs
|
public/packs
|
||||||
public/packs-test
|
public/packs-test
|
||||||
|
public/sw.js
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
@ -6,3 +6,4 @@ plugins:
|
||||||
- last 2 versions
|
- last 2 versions
|
||||||
- IE >= 11
|
- IE >= 11
|
||||||
- iOS >= 9
|
- iOS >= 9
|
||||||
|
postcss-object-fit-images: {}
|
||||||
|
|
5
Gemfile
5
Gemfile
|
@ -28,6 +28,7 @@ gem 'devise', '~> 4.2'
|
||||||
gem 'devise-two-factor', '~> 3.0'
|
gem 'devise-two-factor', '~> 3.0'
|
||||||
gem 'doorkeeper', '~> 4.2'
|
gem 'doorkeeper', '~> 4.2'
|
||||||
gem 'fast_blank', '~> 1.0'
|
gem 'fast_blank', '~> 1.0'
|
||||||
|
gem 'gemoji', '~> 3.0'
|
||||||
gem 'goldfinger', '~> 1.2'
|
gem 'goldfinger', '~> 1.2'
|
||||||
gem 'hiredis', '~> 0.6'
|
gem 'hiredis', '~> 0.6'
|
||||||
gem 'redis-namespace', '~> 1.5'
|
gem 'redis-namespace', '~> 1.5'
|
||||||
|
@ -35,6 +36,7 @@ gem 'htmlentities', '~> 4.3'
|
||||||
gem 'http', '~> 2.2'
|
gem 'http', '~> 2.2'
|
||||||
gem 'http_accept_language', '~> 2.1'
|
gem 'http_accept_language', '~> 2.1'
|
||||||
gem 'httplog', '~> 0.99'
|
gem 'httplog', '~> 0.99'
|
||||||
|
gem 'idn-ruby', require: 'idn'
|
||||||
gem 'kaminari', '~> 1.0'
|
gem 'kaminari', '~> 1.0'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'mime-types', '~> 3.1'
|
gem 'mime-types', '~> 3.1'
|
||||||
|
@ -64,6 +66,7 @@ gem 'statsd-instrument', '~> 2.1'
|
||||||
gem 'twitter-text', '~> 1.14'
|
gem 'twitter-text', '~> 1.14'
|
||||||
gem 'tzinfo-data', '~> 1.2017'
|
gem 'tzinfo-data', '~> 1.2017'
|
||||||
gem 'webpacker', '~> 2.0'
|
gem 'webpacker', '~> 2.0'
|
||||||
|
gem 'webpush'
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem 'fabrication', '~> 2.16'
|
gem 'fabrication', '~> 2.16'
|
||||||
|
@ -77,7 +80,7 @@ group :test do
|
||||||
gem 'capybara', '~> 2.14'
|
gem 'capybara', '~> 2.14'
|
||||||
gem 'climate_control', '~> 0.2'
|
gem 'climate_control', '~> 0.2'
|
||||||
gem 'faker', '~> 1.7'
|
gem 'faker', '~> 1.7'
|
||||||
gem 'microformats2', '~> 3.0'
|
gem 'microformats', '~> 4.0'
|
||||||
gem 'rails-controller-testing', '~> 1.0'
|
gem 'rails-controller-testing', '~> 1.0'
|
||||||
gem 'rspec-sidekiq', '~> 3.0'
|
gem 'rspec-sidekiq', '~> 3.0'
|
||||||
gem 'simplecov', '~> 0.14', require: false
|
gem 'simplecov', '~> 0.14', require: false
|
||||||
|
|
14
Gemfile.lock
14
Gemfile.lock
|
@ -163,6 +163,7 @@ GEM
|
||||||
fuubar (2.2.0)
|
fuubar (2.2.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
ruby-progressbar (~> 1.4)
|
ruby-progressbar (~> 1.4)
|
||||||
|
gemoji (3.0.0)
|
||||||
globalid (0.4.0)
|
globalid (0.4.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
goldfinger (1.2.0)
|
goldfinger (1.2.0)
|
||||||
|
@ -181,6 +182,7 @@ GEM
|
||||||
hashdiff (0.3.4)
|
hashdiff (0.3.4)
|
||||||
highline (1.7.8)
|
highline (1.7.8)
|
||||||
hiredis (0.6.1)
|
hiredis (0.6.1)
|
||||||
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (2.2.2)
|
http (2.2.2)
|
||||||
addressable (~> 2.3)
|
addressable (~> 2.3)
|
||||||
|
@ -206,9 +208,11 @@ GEM
|
||||||
parser (>= 2.2.3.0)
|
parser (>= 2.2.3.0)
|
||||||
rainbow (~> 2.2)
|
rainbow (~> 2.2)
|
||||||
terminal-table (>= 1.5.1)
|
terminal-table (>= 1.5.1)
|
||||||
|
idn-ruby (0.1.0)
|
||||||
jmespath (1.3.1)
|
jmespath (1.3.1)
|
||||||
json (2.1.0)
|
json (2.1.0)
|
||||||
jsonapi-renderer (0.1.2)
|
jsonapi-renderer (0.1.2)
|
||||||
|
jwt (1.5.6)
|
||||||
kaminari (1.0.1)
|
kaminari (1.0.1)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
kaminari-actionview (= 1.0.1)
|
kaminari-actionview (= 1.0.1)
|
||||||
|
@ -239,7 +243,7 @@ GEM
|
||||||
mail (2.6.6)
|
mail (2.6.6)
|
||||||
mime-types (>= 1.16, < 4)
|
mime-types (>= 1.16, < 4)
|
||||||
method_source (0.8.2)
|
method_source (0.8.2)
|
||||||
microformats2 (3.1.0)
|
microformats (4.0.7)
|
||||||
json
|
json
|
||||||
nokogiri
|
nokogiri
|
||||||
mime-types (3.1)
|
mime-types (3.1)
|
||||||
|
@ -475,6 +479,9 @@ GEM
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
multi_json (~> 1.2)
|
multi_json (~> 1.2)
|
||||||
railties (>= 4.2)
|
railties (>= 4.2)
|
||||||
|
webpush (0.3.2)
|
||||||
|
hkdf (~> 0.2)
|
||||||
|
jwt
|
||||||
websocket-driver (0.6.5)
|
websocket-driver (0.6.5)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.2)
|
websocket-extensions (0.1.2)
|
||||||
|
@ -513,6 +520,7 @@ DEPENDENCIES
|
||||||
faker (~> 1.7)
|
faker (~> 1.7)
|
||||||
fast_blank (~> 1.0)
|
fast_blank (~> 1.0)
|
||||||
fuubar (~> 2.2)
|
fuubar (~> 2.2)
|
||||||
|
gemoji (~> 3.0)
|
||||||
goldfinger (~> 1.2)
|
goldfinger (~> 1.2)
|
||||||
hamlit-rails (~> 0.2)
|
hamlit-rails (~> 0.2)
|
||||||
hiredis (~> 0.6)
|
hiredis (~> 0.6)
|
||||||
|
@ -521,12 +529,13 @@ DEPENDENCIES
|
||||||
http_accept_language (~> 2.1)
|
http_accept_language (~> 2.1)
|
||||||
httplog (~> 0.99)
|
httplog (~> 0.99)
|
||||||
i18n-tasks (~> 0.9)
|
i18n-tasks (~> 0.9)
|
||||||
|
idn-ruby
|
||||||
kaminari (~> 1.0)
|
kaminari (~> 1.0)
|
||||||
letter_opener (~> 1.4)
|
letter_opener (~> 1.4)
|
||||||
letter_opener_web (~> 1.3)
|
letter_opener_web (~> 1.3)
|
||||||
link_header (~> 0.0)
|
link_header (~> 0.0)
|
||||||
lograge (~> 0.5)
|
lograge (~> 0.5)
|
||||||
microformats2 (~> 3.0)
|
microformats (~> 4.0)
|
||||||
mime-types (~> 3.1)
|
mime-types (~> 3.1)
|
||||||
nokogiri (~> 1.7)
|
nokogiri (~> 1.7)
|
||||||
oj (~> 3.0)
|
oj (~> 3.0)
|
||||||
|
@ -573,6 +582,7 @@ DEPENDENCIES
|
||||||
uglifier (~> 3.2)
|
uglifier (~> 3.2)
|
||||||
webmock (~> 3.0)
|
webmock (~> 3.0)
|
||||||
webpacker (~> 2.0)
|
webpacker (~> 2.0)
|
||||||
|
webpush
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 2.4.1p111
|
ruby 2.4.1p111
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class AccountsController < ApplicationController
|
class AccountsController < ApplicationController
|
||||||
include AccountControllerConcern
|
include AccountControllerConcern
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
def show
|
def show
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
|
@ -15,7 +16,9 @@ class AccountsController < ApplicationController
|
||||||
render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
|
render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
|
||||||
end
|
end
|
||||||
|
|
||||||
format.activitystreams2
|
format.json do
|
||||||
|
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
28
app/controllers/activitypub/outboxes_controller.rb
Normal file
28
app/controllers/activitypub/outboxes_controller.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::OutboxesController < Api::BaseController
|
||||||
|
before_action :set_account
|
||||||
|
|
||||||
|
def show
|
||||||
|
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
|
||||||
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
|
||||||
|
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_account
|
||||||
|
@account = Account.find_local!(params[:account_username])
|
||||||
|
end
|
||||||
|
|
||||||
|
def outbox_presenter
|
||||||
|
ActivityPub::CollectionPresenter.new(
|
||||||
|
id: account_outbox_url(@account),
|
||||||
|
type: :ordered,
|
||||||
|
current: account_outbox_url(@account),
|
||||||
|
size: @account.statuses_count,
|
||||||
|
items: @statuses
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,27 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Api::ActivityPub::ActivitiesController < Api::BaseController
|
|
||||||
include Authorization
|
|
||||||
|
|
||||||
# before_action :set_follow, only: [:show_follow]
|
|
||||||
before_action :set_status, only: [:show_status]
|
|
||||||
|
|
||||||
respond_to :activitystreams2
|
|
||||||
|
|
||||||
# Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity.
|
|
||||||
def show_status
|
|
||||||
authorize @status, :show?
|
|
||||||
|
|
||||||
if @status.reblog?
|
|
||||||
render :show_status_announce
|
|
||||||
else
|
|
||||||
render :show_status_create
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_status
|
|
||||||
@status = Status.find(params[:id])
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,19 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Api::ActivityPub::NotesController < Api::BaseController
|
|
||||||
include Authorization
|
|
||||||
|
|
||||||
before_action :set_status
|
|
||||||
|
|
||||||
respond_to :activitystreams2
|
|
||||||
|
|
||||||
def show
|
|
||||||
authorize @status, :show?
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_status
|
|
||||||
@status = Status.find(params[:id])
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,69 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Api::ActivityPub::OutboxController < Api::BaseController
|
|
||||||
before_action :set_account
|
|
||||||
|
|
||||||
respond_to :activitystreams2
|
|
||||||
|
|
||||||
def show
|
|
||||||
if params[:max_id] || params[:since_id]
|
|
||||||
show_outbox_page
|
|
||||||
else
|
|
||||||
show_base_outbox
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def show_base_outbox
|
|
||||||
@statuses = Status.as_outbox_timeline(@account)
|
|
||||||
@statuses = cache_collection(@statuses)
|
|
||||||
|
|
||||||
set_maps(@statuses)
|
|
||||||
|
|
||||||
set_first_last_page(@statuses)
|
|
||||||
|
|
||||||
render :show
|
|
||||||
end
|
|
||||||
|
|
||||||
def show_outbox_page
|
|
||||||
all_statuses = Status.as_outbox_timeline(@account)
|
|
||||||
@statuses = all_statuses.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
|
|
||||||
|
|
||||||
all_statuses = cache_collection(all_statuses)
|
|
||||||
@statuses = cache_collection(@statuses)
|
|
||||||
|
|
||||||
set_maps(@statuses)
|
|
||||||
|
|
||||||
set_first_last_page(all_statuses)
|
|
||||||
|
|
||||||
@next_page_url = api_activitypub_outbox_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
|
|
||||||
@prev_page_url = api_activitypub_outbox_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
|
|
||||||
|
|
||||||
@paginated = @next_page_url || @prev_page_url
|
|
||||||
@part_of_url = api_activitypub_outbox_url
|
|
||||||
|
|
||||||
set_pagination_headers(@next_page_url, @prev_page_url)
|
|
||||||
|
|
||||||
render :show_page
|
|
||||||
end
|
|
||||||
|
|
||||||
def cache_collection(raw)
|
|
||||||
super(raw, Status)
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_account
|
|
||||||
@account = Account.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_first_last_page(statuses) # rubocop:disable Style/AccessorMethodName
|
|
||||||
return if statuses.empty?
|
|
||||||
|
|
||||||
@first_page_url = api_activitypub_outbox_url(max_id: statuses.first.id + 1)
|
|
||||||
@last_page_url = api_activitypub_outbox_url(since_id: statuses.last.id - 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
def pagination_params(core_params)
|
|
||||||
params.permit(:local, :limit).merge(core_params)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,6 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::PushController < Api::BaseController
|
class Api::PushController < Api::BaseController
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
def update
|
def update
|
||||||
response, status = process_push_request
|
response, status = process_push_request
|
||||||
render plain: response, status: status
|
render plain: response, status: status
|
||||||
|
@ -11,7 +13,7 @@ class Api::PushController < Api::BaseController
|
||||||
def process_push_request
|
def process_push_request
|
||||||
case hub_mode
|
case hub_mode
|
||||||
when 'subscribe'
|
when 'subscribe'
|
||||||
Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds)
|
Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain)
|
||||||
when 'unsubscribe'
|
when 'unsubscribe'
|
||||||
Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback)
|
Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback)
|
||||||
else
|
else
|
||||||
|
@ -57,6 +59,10 @@ class Api::PushController < Api::BaseController
|
||||||
TagManager.instance.web_domain?(hub_topic_domain)
|
TagManager.instance.web_domain?(hub_topic_domain)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def verified_domain
|
||||||
|
return signed_request_account.domain if signed_request_account
|
||||||
|
end
|
||||||
|
|
||||||
def hub_topic_domain
|
def hub_topic_domain
|
||||||
hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '')
|
hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '')
|
||||||
end
|
end
|
||||||
|
|
|
@ -42,7 +42,7 @@ class Api::SubscriptionsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def lease_seconds_or_default
|
def lease_seconds_or_default
|
||||||
(params['hub.lease_seconds'] || 86_400).to_i.seconds
|
(params['hub.lease_seconds'] || 1.day).to_i.seconds
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
|
|
|
@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
|
||||||
|
|
||||||
UnfavouriteWorker.perform_async(current_user.account_id, @status.id)
|
UnfavouriteWorker.perform_async(current_user.account_id, @status.id)
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer
|
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
||||||
authorize status_for_destroy, :unreblog?
|
authorize status_for_destroy, :unreblog?
|
||||||
RemovalWorker.perform_async(status_for_destroy.id)
|
RemovalWorker.perform_async(status_for_destroy.id)
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer
|
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
39
app/controllers/api/web/push_subscriptions_controller.rb
Normal file
39
app/controllers/api/web/push_subscriptions_controller.rb
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::Web::PushSubscriptionsController < Api::BaseController
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
before_action :require_user!
|
||||||
|
|
||||||
|
def create
|
||||||
|
params.require(:data).require(:endpoint)
|
||||||
|
params.require(:data).require(:keys).require([:auth, :p256dh])
|
||||||
|
|
||||||
|
active_session = current_session
|
||||||
|
|
||||||
|
unless active_session.web_push_subscription.nil?
|
||||||
|
active_session.web_push_subscription.destroy!
|
||||||
|
active_session.update!(web_push_subscription: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
web_subscription = ::Web::PushSubscription.create!(
|
||||||
|
endpoint: params[:data][:endpoint],
|
||||||
|
key_p256dh: params[:data][:keys][:p256dh],
|
||||||
|
key_auth: params[:data][:keys][:auth]
|
||||||
|
)
|
||||||
|
|
||||||
|
active_session.update!(web_push_subscription: web_subscription)
|
||||||
|
|
||||||
|
render json: web_subscription.as_payload
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
params.require([:id, :data])
|
||||||
|
|
||||||
|
web_subscription = ::Web::PushSubscription.find(params[:id])
|
||||||
|
|
||||||
|
web_subscription.update!(data: params[:data])
|
||||||
|
|
||||||
|
render json: web_subscription.as_payload
|
||||||
|
end
|
||||||
|
end
|
87
app/controllers/concerns/signature_verification.rb
Normal file
87
app/controllers/concerns/signature_verification.rb
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Implemented according to HTTP signatures (Draft 6)
|
||||||
|
# <https://tools.ietf.org/html/draft-cavage-http-signatures-06>
|
||||||
|
module SignatureVerification
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def signed_request?
|
||||||
|
request.headers['Signature'].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_request_account
|
||||||
|
return @signed_request_account if defined?(@signed_request_account)
|
||||||
|
|
||||||
|
unless signed_request?
|
||||||
|
@signed_request_account = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
raw_signature = request.headers['Signature']
|
||||||
|
signature_params = {}
|
||||||
|
|
||||||
|
raw_signature.split(',').each do |part|
|
||||||
|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
|
||||||
|
next if parsed_parts.nil? || parsed_parts.size != 3
|
||||||
|
signature_params[parsed_parts[1]] = parsed_parts[2]
|
||||||
|
end
|
||||||
|
|
||||||
|
if incompatible_signature?(signature_params)
|
||||||
|
@signed_request_account = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
|
||||||
|
|
||||||
|
if account.nil?
|
||||||
|
@signed_request_account = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
signature = Base64.decode64(signature_params['signature'])
|
||||||
|
compare_signed_string = build_signed_string(signature_params['headers'])
|
||||||
|
|
||||||
|
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
|
||||||
|
@signed_request_account = account
|
||||||
|
@signed_request_account
|
||||||
|
else
|
||||||
|
@signed_request_account = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_signed_string(signed_headers)
|
||||||
|
signed_headers = 'date' if signed_headers.blank?
|
||||||
|
|
||||||
|
signed_headers.split(' ').map do |signed_header|
|
||||||
|
if signed_header == Request::REQUEST_TARGET
|
||||||
|
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
|
||||||
|
else
|
||||||
|
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
|
||||||
|
end
|
||||||
|
end.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def matches_time_window?
|
||||||
|
begin
|
||||||
|
time_sent = DateTime.httpdate(request.headers['Date'])
|
||||||
|
rescue ArgumentError
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
(Time.now.utc - time_sent).abs <= 30
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_header_name(name)
|
||||||
|
name.split(/-/).map(&:capitalize).join('-')
|
||||||
|
end
|
||||||
|
|
||||||
|
def incompatible_signature?(signature_params)
|
||||||
|
signature_params['keyId'].blank? ||
|
||||||
|
signature_params['signature'].blank? ||
|
||||||
|
signature_params['algorithm'].blank? ||
|
||||||
|
signature_params['algorithm'] != 'rsa-sha256' ||
|
||||||
|
!signature_params['keyId'].start_with?('acct:')
|
||||||
|
end
|
||||||
|
end
|
|
@ -5,5 +5,25 @@ class FollowerAccountsController < ApplicationController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
|
@follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html
|
||||||
|
|
||||||
|
format.json do
|
||||||
|
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def collection_presenter
|
||||||
|
ActivityPub::CollectionPresenter.new(
|
||||||
|
id: account_followers_url(@account),
|
||||||
|
type: :ordered,
|
||||||
|
current: account_followers_url(@account),
|
||||||
|
size: @account.followers_count,
|
||||||
|
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,5 +5,25 @@ class FollowingAccountsController < ApplicationController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
|
@follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html
|
||||||
|
|
||||||
|
format.json do
|
||||||
|
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def collection_presenter
|
||||||
|
ActivityPub::CollectionPresenter.new(
|
||||||
|
id: account_following_index_url(@account),
|
||||||
|
type: :ordered,
|
||||||
|
current: account_following_index_url(@account),
|
||||||
|
size: @account.following_count,
|
||||||
|
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,6 +22,7 @@ class HomeController < ApplicationController
|
||||||
def initial_state_params
|
def initial_state_params
|
||||||
{
|
{
|
||||||
settings: Web::Setting.find_by(user: current_user)&.data || {},
|
settings: Web::Setting.find_by(user: current_user)&.data || {},
|
||||||
|
push_subscription: current_account.user.web_push_subscription(current_session),
|
||||||
current_account: current_account,
|
current_account: current_account,
|
||||||
token: current_session.token,
|
token: current_session.token,
|
||||||
admin: Account.find_local(Setting.site_contact_username),
|
admin: Account.find_local(Setting.site_contact_username),
|
||||||
|
|
|
@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController
|
||||||
:setting_delete_modal,
|
:setting_delete_modal,
|
||||||
:setting_auto_play_gif,
|
:setting_auto_play_gif,
|
||||||
:setting_system_font_ui,
|
:setting_system_font_ui,
|
||||||
|
:setting_noindex,
|
||||||
notification_emails: %i(follow follow_request reblog favourite mention digest),
|
notification_emails: %i(follow follow_request reblog favourite mention digest),
|
||||||
interactions: %i(must_be_follower must_be_following)
|
interactions: %i(must_be_follower must_be_following)
|
||||||
)
|
)
|
||||||
|
|
|
@ -11,10 +11,22 @@ class StatusesController < ApplicationController
|
||||||
before_action :check_account_suspension
|
before_action :check_account_suspension
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
|
respond_to do |format|
|
||||||
@descendants = cache_collection(@status.descendants(current_account), Status)
|
format.html do
|
||||||
|
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
|
||||||
|
@descendants = cache_collection(@status.descendants(current_account), Status)
|
||||||
|
|
||||||
render 'stream_entries/show'
|
render 'stream_entries/show'
|
||||||
|
end
|
||||||
|
|
||||||
|
format.json do
|
||||||
|
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def activity
|
||||||
|
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class StreamEntriesController < ApplicationController
|
class StreamEntriesController < ApplicationController
|
||||||
include Authorization
|
include Authorization
|
||||||
|
include SignatureVerification
|
||||||
|
|
||||||
layout 'public'
|
layout 'public'
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,27 @@ class TagsController < ApplicationController
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@tag = Tag.find_by!(name: params[:id].downcase)
|
@tag = Tag.find_by!(name: params[:id].downcase)
|
||||||
@statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
|
@statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
|
||||||
@statuses = cache_collection(@statuses, Status)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html
|
||||||
|
|
||||||
|
format.json do
|
||||||
|
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def collection_presenter
|
||||||
|
ActivityPub::CollectionPresenter.new(
|
||||||
|
id: tag_url(@tag),
|
||||||
|
type: :ordered,
|
||||||
|
current: tag_url(@tag),
|
||||||
|
size: @tag.statuses.count,
|
||||||
|
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Activitystreams2BuilderHelper
|
|
||||||
# Gets a usable name for an account, using display name or username.
|
|
||||||
def account_name(account)
|
|
||||||
account.display_name.presence || account.username
|
|
||||||
end
|
|
||||||
end
|
|
19
app/helpers/emoji_helper.rb
Normal file
19
app/helpers/emoji_helper.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module EmojiHelper
|
||||||
|
EMOJI_PATTERN = /(?<=[^[:alnum:]:]|\n|^):([\w+-]+):(?=[^[:alnum:]:]|$)/x
|
||||||
|
|
||||||
|
def emojify(text)
|
||||||
|
return text if text.blank?
|
||||||
|
|
||||||
|
text.gsub(EMOJI_PATTERN) do |match|
|
||||||
|
emoji = Emoji.find_by_alias($1) # rubocop:disable Rails/DynamicFindBy,Style/PerlBackrefs
|
||||||
|
|
||||||
|
if emoji
|
||||||
|
emoji.raw
|
||||||
|
else
|
||||||
|
match
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,17 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module HttpHelper
|
|
||||||
def http_client(options = {})
|
|
||||||
timeout = { write: 10, connect: 10, read: 10 }.merge(options)
|
|
||||||
|
|
||||||
HTTP.headers(user_agent: user_agent)
|
|
||||||
.timeout(:per_operation, timeout)
|
|
||||||
.follow
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def user_agent
|
|
||||||
@user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +http://#{Rails.configuration.x.local_domain}/)"
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -2,8 +2,6 @@ import api from '../api';
|
||||||
|
|
||||||
import { updateTimeline } from './timelines';
|
import { updateTimeline } from './timelines';
|
||||||
|
|
||||||
import * as emojione from 'emojione';
|
|
||||||
|
|
||||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||||
|
@ -74,10 +72,12 @@ export function mentionCompose(account, router) {
|
||||||
|
|
||||||
export function submitCompose() {
|
export function submitCompose() {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
let status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], ''));
|
const status = getState().getIn(['compose', 'text'], '');
|
||||||
|
|
||||||
if (!status || !status.length) {
|
if (!status || !status.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(submitComposeRequest());
|
dispatch(submitComposeRequest());
|
||||||
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
|
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
|
||||||
status = status + ' 👁️';
|
status = status + ' 👁️';
|
||||||
|
|
52
app/javascript/mastodon/actions/push_notifications.js
Normal file
52
app/javascript/mastodon/actions/push_notifications.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
||||||
|
export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
|
||||||
|
export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
|
||||||
|
export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
|
||||||
|
|
||||||
|
export function setBrowserSupport (value) {
|
||||||
|
return {
|
||||||
|
type: SET_BROWSER_SUPPORT,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSubscription (subscription) {
|
||||||
|
return {
|
||||||
|
type: SET_SUBSCRIPTION,
|
||||||
|
subscription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSubscription () {
|
||||||
|
return {
|
||||||
|
type: CLEAR_SUBSCRIPTION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeAlerts(key, value) {
|
||||||
|
return dispatch => {
|
||||||
|
dispatch({
|
||||||
|
type: ALERTS_CHANGE,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(saveSettings());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings() {
|
||||||
|
return (_, getState) => {
|
||||||
|
const state = getState().get('push_notifications');
|
||||||
|
const subscription = state.get('subscription');
|
||||||
|
const alerts = state.get('alerts');
|
||||||
|
|
||||||
|
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
|
||||||
|
data: {
|
||||||
|
alerts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -5,6 +5,8 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
src: PropTypes.string.isRequired,
|
src: PropTypes.string.isRequired,
|
||||||
|
width: PropTypes.number,
|
||||||
|
height: PropTypes.number,
|
||||||
time: PropTypes.number,
|
time: PropTypes.number,
|
||||||
controls: PropTypes.bool.isRequired,
|
controls: PropTypes.bool.isRequired,
|
||||||
muted: PropTypes.bool.isRequired,
|
muted: PropTypes.bool.isRequired,
|
||||||
|
@ -30,7 +32,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<div className='extended-video-player'>
|
<div className='extended-video-player' style={{ width: this.props.width, height: this.props.height }}>
|
||||||
<video
|
<video
|
||||||
ref={this.setRef}
|
ref={this.setRef}
|
||||||
src={this.props.src}
|
src={this.props.src}
|
||||||
|
|
|
@ -6,11 +6,18 @@ export default class LoadMore extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
|
visible: PropTypes.bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
visible: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { visible } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className='load-more' onClick={this.props.onClick}>
|
<button className='load-more' disabled={!visible} style={{ opacity: visible ? 1 : 0 }} onClick={this.props.onClick}>
|
||||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -101,13 +101,9 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
render () {
|
render () {
|
||||||
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
|
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
|
||||||
|
|
||||||
let loadMore = null;
|
const loadMore = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />;
|
||||||
let scrollableArea = null;
|
let scrollableArea = null;
|
||||||
|
|
||||||
if (!isLoading && statusIds.size > 0 && hasMore) {
|
|
||||||
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading || statusIds.size > 0 || !emptyMessage) {
|
if (isLoading || statusIds.size > 0 || !emptyMessage) {
|
||||||
scrollableArea = (
|
scrollableArea = (
|
||||||
<div className='scrollable' ref={this.setRef}>
|
<div className='scrollable' ref={this.setRef}>
|
||||||
|
|
|
@ -1,49 +1,28 @@
|
||||||
import emojione from 'emojione';
|
import { unicodeToFilename } from './emojione_light';
|
||||||
import Trie from 'substring-trie';
|
import Trie from 'substring-trie';
|
||||||
|
|
||||||
const mappedUnicode = emojione.mapUnicodeToShort();
|
const trie = new Trie(Object.keys(unicodeToFilename));
|
||||||
const trie = new Trie(Object.keys(emojione.jsEscapeMap));
|
|
||||||
|
|
||||||
function emojify(str) {
|
function emojify(str) {
|
||||||
// This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
|
// This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
|
||||||
// and replacing valid shortnames like :smile: and :wink: as well as unicode strings
|
// and replacing valid unicode strings
|
||||||
// that _aren't_ within tags with an <img> version.
|
// that _aren't_ within tags with an <img> version.
|
||||||
// The goal is to be the same as an emojione.regShortNames/regUnicode replacement, but faster.
|
// The goal is to be the same as an emojione.regUnicode replacement, but faster.
|
||||||
let i = -1;
|
let i = -1;
|
||||||
let insideTag = false;
|
let insideTag = false;
|
||||||
let insideShortname = false;
|
|
||||||
let shortnameStartIndex = -1;
|
|
||||||
let match;
|
let match;
|
||||||
while (++i < str.length) {
|
while (++i < str.length) {
|
||||||
const char = str.charAt(i);
|
const char = str.charAt(i);
|
||||||
if (insideShortname && char === ':') {
|
if (insideTag && char === '>') {
|
||||||
const shortname = str.substring(shortnameStartIndex, i + 1);
|
|
||||||
if (shortname in emojione.emojioneList) {
|
|
||||||
const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
|
|
||||||
const alt = emojione.convert(unicode.toUpperCase());
|
|
||||||
const replacement = `<img draggable="false" class="emojione" alt="${alt}" title="${shortname}" src="/emoji/${unicode}.svg" />`;
|
|
||||||
str = str.substring(0, shortnameStartIndex) + replacement + str.substring(i + 1);
|
|
||||||
i += (replacement.length - shortname.length - 1); // jump ahead the length we've added to the string
|
|
||||||
} else {
|
|
||||||
i--; // stray colon, try again
|
|
||||||
}
|
|
||||||
insideShortname = false;
|
|
||||||
} else if (insideTag && char === '>') {
|
|
||||||
insideTag = false;
|
insideTag = false;
|
||||||
} else if (char === '<') {
|
} else if (char === '<') {
|
||||||
insideTag = true;
|
insideTag = true;
|
||||||
insideShortname = false;
|
|
||||||
} else if (!insideTag && char === ':') {
|
|
||||||
insideShortname = true;
|
|
||||||
shortnameStartIndex = i;
|
|
||||||
} else if (!insideTag && (match = trie.search(str.substring(i)))) {
|
} else if (!insideTag && (match = trie.search(str.substring(i)))) {
|
||||||
const unicodeStr = match;
|
const unicodeStr = match;
|
||||||
if (unicodeStr in emojione.jsEscapeMap) {
|
if (unicodeStr in unicodeToFilename) {
|
||||||
const unicode = emojione.jsEscapeMap[unicodeStr];
|
const filename = unicodeToFilename[unicodeStr];
|
||||||
const short = mappedUnicode[unicode];
|
const alt = unicodeStr;
|
||||||
const filename = emojione.emojioneList[short].fname;
|
const replacement = `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
|
||||||
const alt = emojione.convert(unicode.toUpperCase());
|
|
||||||
const replacement = `<img draggable="false" class="emojione" alt="${alt}" title="${short}" src="/emoji/${filename}.svg" />`;
|
|
||||||
str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
|
str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
|
||||||
i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
|
i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
|
||||||
}
|
}
|
||||||
|
|
11
app/javascript/mastodon/emojione_light.js
Normal file
11
app/javascript/mastodon/emojione_light.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// @preval
|
||||||
|
// Force tree shaking on emojione by exposing just a subset of its functionality
|
||||||
|
|
||||||
|
const emojione = require('emojione');
|
||||||
|
|
||||||
|
const mappedUnicode = emojione.mapUnicodeToShort();
|
||||||
|
|
||||||
|
module.exports.unicodeToFilename = Object.keys(emojione.jsEscapeMap)
|
||||||
|
.map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
|
||||||
|
.map(([unicodeStr, shortCode]) => ({ [unicodeStr]: emojione.emojioneList[shortCode].fname }))
|
||||||
|
.reduce((x, y) => Object.assign(x, y), { });
|
|
@ -1,2 +1,5 @@
|
||||||
import 'intersection-observer';
|
import 'intersection-observer';
|
||||||
import 'requestidlecallback';
|
import 'requestidlecallback';
|
||||||
|
import objectFitImages from 'object-fit-images';
|
||||||
|
|
||||||
|
objectFitImages();
|
||||||
|
|
|
@ -140,7 +140,8 @@ export default class ComposeForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleEmojiPick = (data) => {
|
handleEmojiPick = (data) => {
|
||||||
const position = this.autosuggestTextarea.textarea.selectionStart;
|
const position = this.autosuggestTextarea.textarea.selectionStart;
|
||||||
this._restoreCaret = position + data.shortname.length + 1;
|
const emojiChar = String.fromCodePoint(parseInt(data.unicode, 16));
|
||||||
|
this._restoreCaret = position + emojiChar.length + 1;
|
||||||
this.props.onPickEmoji(position, data);
|
this.props.onPickEmoji(position, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -109,11 +109,12 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||||
<Dropdown ref={this.setRef} className='emoji-picker__dropdown' onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
|
<Dropdown ref={this.setRef} className='emoji-picker__dropdown' onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
|
||||||
<DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}>
|
<DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}>
|
||||||
<img
|
<img
|
||||||
draggable='false'
|
|
||||||
className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
|
className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
|
||||||
alt='🙂' src='/emoji/1f602.svg'
|
alt='🙂'
|
||||||
|
src='/emoji/1f602.svg'
|
||||||
/>
|
/>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
|
|
||||||
<DropdownContent className='dropdown__left'>
|
<DropdownContent className='dropdown__left'>
|
||||||
{
|
{
|
||||||
this.state.active && !this.state.loading &&
|
this.state.active && !this.state.loading &&
|
||||||
|
|
|
@ -2,11 +2,11 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import LoadingIndicator from '../../components/loading_indicator';
|
|
||||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
|
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
|
import ColumnHeader from '../../components/column_header';
|
||||||
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import StatusList from '../../components/status_list';
|
import StatusList from '../../components/status_list';
|
||||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
@ -16,8 +16,6 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
|
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
|
||||||
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
|
|
||||||
me: state.getIn(['meta', 'me']),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@connect(mapStateToProps)
|
@connect(mapStateToProps)
|
||||||
|
@ -27,34 +25,64 @@ export default class Favourites extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
statusIds: ImmutablePropTypes.list.isRequired,
|
statusIds: ImmutablePropTypes.list.isRequired,
|
||||||
loaded: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
me: PropTypes.number.isRequired,
|
columnId: PropTypes.string,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillMount () {
|
componentWillMount () {
|
||||||
this.props.dispatch(fetchFavouritedStatuses());
|
this.props.dispatch(fetchFavouritedStatuses());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePin = () => {
|
||||||
|
const { columnId, dispatch } = this.props;
|
||||||
|
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(removeColumn(columnId));
|
||||||
|
} else {
|
||||||
|
dispatch(addColumn('FAVOURITES', {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMove = (dir) => {
|
||||||
|
const { columnId, dispatch } = this.props;
|
||||||
|
dispatch(moveColumn(columnId, dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHeaderClick = () => {
|
||||||
|
this.column.scrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.column = c;
|
||||||
|
}
|
||||||
|
|
||||||
handleScrollToBottom = () => {
|
handleScrollToBottom = () => {
|
||||||
this.props.dispatch(expandFavouritedStatuses());
|
this.props.dispatch(expandFavouritedStatuses());
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { loaded, intl } = this.props;
|
const { intl, statusIds, columnId, multiColumn } = this.props;
|
||||||
|
const pinned = !!columnId;
|
||||||
if (!loaded) {
|
|
||||||
return (
|
|
||||||
<Column>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column icon='star' heading={intl.formatMessage(messages.heading)}>
|
<Column ref={this.setRef}>
|
||||||
<ColumnBackButtonSlim />
|
<ColumnHeader
|
||||||
<StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} />
|
icon='star'
|
||||||
|
title={intl.formatMessage(messages.heading)}
|
||||||
|
onPin={this.handlePin}
|
||||||
|
onMove={this.handleMove}
|
||||||
|
onClick={this.handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusList
|
||||||
|
trackScroll={!pinned}
|
||||||
|
statusIds={statusIds}
|
||||||
|
scrollKey={`favourited_statuses-${columnId}`}
|
||||||
|
onScrollToBottom={this.handleScrollToBottom}
|
||||||
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
settings: ImmutablePropTypes.map.isRequired,
|
settings: ImmutablePropTypes.map.isRequired,
|
||||||
|
pushSettings: ImmutablePropTypes.map.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
onClear: PropTypes.func.isRequired,
|
onClear: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onPushChange = (key, checked) => {
|
||||||
|
this.props.onChange(['push', ...key], checked);
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { settings, onChange, onClear } = this.props;
|
const { settings, pushSettings, onChange, onClear } = this.props;
|
||||||
|
|
||||||
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
||||||
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
||||||
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
|
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
|
||||||
|
|
||||||
|
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
|
||||||
|
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
|
||||||
|
const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
|
@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
|
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
|
<SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
|
||||||
|
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
|
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
|
<SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
|
||||||
|
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
|
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
|
<SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
|
||||||
|
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
|
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
|
||||||
|
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
|
<SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
|
||||||
|
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
|
||||||
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
|
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent {
|
||||||
settings: ImmutablePropTypes.map.isRequired,
|
settings: ImmutablePropTypes.map.isRequired,
|
||||||
settingKey: PropTypes.array.isRequired,
|
settingKey: PropTypes.array.isRequired,
|
||||||
label: PropTypes.node.isRequired,
|
label: PropTypes.node.isRequired,
|
||||||
|
meta: PropTypes.node,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { prefix, settings, settingKey, label } = this.props;
|
const { prefix, settings, settingKey, label, meta } = this.props;
|
||||||
const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
|
const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='setting-toggle'>
|
<div className='setting-toggle'>
|
||||||
<Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
|
<Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
|
||||||
<label htmlFor={id} className='setting-toggle__label'>{label}</label>
|
<label htmlFor={id} className='setting-toggle__label'>{label}</label>
|
||||||
|
{meta && <span className='setting-meta__label'>{meta}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import ColumnSettings from '../components/column_settings';
|
import ColumnSettings from '../components/column_settings';
|
||||||
import { changeSetting, saveSettings } from '../../../actions/settings';
|
import { changeSetting, saveSettings } from '../../../actions/settings';
|
||||||
import { clearNotifications } from '../../../actions/notifications';
|
import { clearNotifications } from '../../../actions/notifications';
|
||||||
|
import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
|
||||||
import { openModal } from '../../../actions/modal';
|
import { openModal } from '../../../actions/modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -12,16 +13,22 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
settings: state.getIn(['settings', 'notifications']),
|
settings: state.getIn(['settings', 'notifications']),
|
||||||
|
pushSettings: state.get('push_notifications'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
|
||||||
onChange (key, checked) {
|
onChange (key, checked) {
|
||||||
dispatch(changeSetting(['notifications', ...key], checked));
|
if (key[0] === 'push') {
|
||||||
|
dispatch(changePushNotifications(key.slice(1), checked));
|
||||||
|
} else {
|
||||||
|
dispatch(changeSetting(['notifications', ...key], checked));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onSave () {
|
onSave () {
|
||||||
dispatch(saveSettings());
|
dispatch(saveSettings());
|
||||||
|
dispatch(savePushNotificationSettings());
|
||||||
},
|
},
|
||||||
|
|
||||||
onClear () {
|
onClear () {
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { links, getIndex, getLink } from './tabs_bar';
|
||||||
import BundleContainer from '../containers/bundle_container';
|
import BundleContainer from '../containers/bundle_container';
|
||||||
import ColumnLoading from './column_loading';
|
import ColumnLoading from './column_loading';
|
||||||
import BundleColumnError from './bundle_column_error';
|
import BundleColumnError from './bundle_column_error';
|
||||||
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline } from '../../ui/util/async-components';
|
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
'COMPOSE': Compose,
|
'COMPOSE': Compose,
|
||||||
|
@ -18,6 +18,7 @@ const componentMap = {
|
||||||
'PUBLIC': PublicTimeline,
|
'PUBLIC': PublicTimeline,
|
||||||
'COMMUNITY': CommunityTimeline,
|
'COMMUNITY': CommunityTimeline,
|
||||||
'HASHTAG': HashtagTimeline,
|
'HASHTAG': HashtagTimeline,
|
||||||
|
'FAVOURITES': FavouritedStatuses,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ColumnsArea extends ImmutablePureComponent {
|
export default class ColumnsArea extends ImmutablePureComponent {
|
||||||
|
@ -32,12 +33,33 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
shouldAnimate: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps() {
|
||||||
|
this.setState({ shouldAnimate: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.lastIndex = getIndex(this.context.router.history.location.pathname);
|
||||||
|
this.setState({ shouldAnimate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
this.lastIndex = getIndex(this.context.router.history.location.pathname);
|
||||||
|
this.setState({ shouldAnimate: true });
|
||||||
|
}
|
||||||
|
|
||||||
handleSwipe = (index) => {
|
handleSwipe = (index) => {
|
||||||
window.requestAnimationFrame(() => {
|
this.pendingIndex = index;
|
||||||
window.requestAnimationFrame(() => {
|
}
|
||||||
this.context.router.history.push(getLink(index));
|
|
||||||
});
|
handleAnimationEnd = () => {
|
||||||
});
|
if (typeof this.pendingIndex === 'number') {
|
||||||
|
this.context.router.history.push(getLink(this.pendingIndex));
|
||||||
|
this.pendingIndex = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderView = (link, index) => {
|
renderView = (link, index) => {
|
||||||
|
@ -66,12 +88,14 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { columns, children, singleColumn } = this.props;
|
const { columns, children, singleColumn } = this.props;
|
||||||
|
const { shouldAnimate } = this.state;
|
||||||
|
|
||||||
const columnIndex = getIndex(this.context.router.history.location.pathname);
|
const columnIndex = getIndex(this.context.router.history.location.pathname);
|
||||||
|
this.pendingIndex = null;
|
||||||
|
|
||||||
if (singleColumn) {
|
if (singleColumn) {
|
||||||
return columnIndex !== -1 ? (
|
return columnIndex !== -1 ? (
|
||||||
<ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} animateTransitions={false} style={{ height: '100%' }}>
|
<ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
|
||||||
{links.map(this.renderView)}
|
{links.map(this.renderView)}
|
||||||
</ReactSwipeableViews>
|
</ReactSwipeableViews>
|
||||||
) : <div className='columns-area'>{children}</div>;
|
) : <div className='columns-area'>{children}</div>;
|
||||||
|
|
|
@ -65,8 +65,6 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||||
const { media, intl, onClose } = this.props;
|
const { media, intl, onClose } = this.props;
|
||||||
|
|
||||||
const index = this.getIndex();
|
const index = this.getIndex();
|
||||||
const attachment = media.get(index);
|
|
||||||
const url = attachment.get('url');
|
|
||||||
|
|
||||||
let leftNav, rightNav, content;
|
let leftNav, rightNav, content;
|
||||||
|
|
||||||
|
@ -77,16 +75,18 @@ export default class MediaModal extends ImmutablePureComponent {
|
||||||
rightNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
|
rightNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment.get('type') === 'image') {
|
content = media.map((image) => {
|
||||||
content = media.map((image) => {
|
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
const height = image.getIn(['meta', 'original', 'height']) || null;
|
||||||
const height = image.getIn(['meta', 'original', 'height']) || null;
|
|
||||||
|
|
||||||
|
if (image.get('type') === 'image') {
|
||||||
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />;
|
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />;
|
||||||
}).toArray();
|
} else if (image.get('type') === 'gifv') {
|
||||||
} else if (attachment.get('type') === 'gifv') {
|
return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />;
|
||||||
content = <ExtendedVideoPlayer src={url} muted controls={false} />;
|
}
|
||||||
}
|
|
||||||
|
return null;
|
||||||
|
}).toArray();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal media-modal'>
|
<div className='modal-root__modal media-modal'>
|
||||||
|
|
|
@ -56,12 +56,6 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
return { opacity: spring(0), scale: spring(0.98) };
|
return { opacity: spring(0), scale: spring(0.98) };
|
||||||
}
|
}
|
||||||
|
|
||||||
renderModal = (SpecificComponent) => {
|
|
||||||
const { props, onClose } = this.props;
|
|
||||||
|
|
||||||
return <SpecificComponent {...props} onClose={onClose} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderLoading = () => {
|
renderLoading = () => {
|
||||||
return <ModalLoading />;
|
return <ModalLoading />;
|
||||||
}
|
}
|
||||||
|
@ -97,7 +91,9 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||||
<div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
|
<div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
|
||||||
<div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
|
<div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
|
||||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>{this.renderModal}</BundleContainer>
|
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
|
||||||
|
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
|
||||||
|
</BundleContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -20,11 +20,12 @@ function loadPolyfills() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Latest version of Firefox and Safari do not have IntersectionObserver.
|
// Latest version of Firefox and Safari do not have IntersectionObserver.
|
||||||
// Edge does not have requestIdleCallback.
|
// Edge does not have requestIdleCallback and object-fit CSS property.
|
||||||
// This avoids shipping them all the polyfills.
|
// This avoids shipping them all the polyfills.
|
||||||
const needsExtraPolyfills = !(
|
const needsExtraPolyfills = !(
|
||||||
window.IntersectionObserver &&
|
window.IntersectionObserver &&
|
||||||
window.requestIdleCallback
|
window.requestIdleCallback &&
|
||||||
|
'object-fit' in (new Image()).style
|
||||||
);
|
);
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "المُفَضَّلة :",
|
"notifications.column_settings.favourite": "المُفَضَّلة :",
|
||||||
"notifications.column_settings.follow": "متابعُون جُدُد :",
|
"notifications.column_settings.follow": "متابعُون جُدُد :",
|
||||||
"notifications.column_settings.mention": "الإشارات :",
|
"notifications.column_settings.mention": "الإشارات :",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "الترقيّات:",
|
"notifications.column_settings.reblog": "الترقيّات:",
|
||||||
"notifications.column_settings.show": "إعرِضها في عمود",
|
"notifications.column_settings.show": "إعرِضها في عمود",
|
||||||
"notifications.column_settings.sound": "أصدر صوتا",
|
"notifications.column_settings.sound": "أصدر صوتا",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "إبلاغ",
|
"report.target": "إبلاغ",
|
||||||
"search.placeholder": "ابحث",
|
"search.placeholder": "ابحث",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "تعذرت ترقية هذا المنشور",
|
"status.cannot_reblog": "تعذرت ترقية هذا المنشور",
|
||||||
"status.delete": "إحذف",
|
"status.delete": "إحذف",
|
||||||
"status.favourite": "أضف إلى المفضلة",
|
"status.favourite": "أضف إلى المفضلة",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Предпочитани:",
|
"notifications.column_settings.favourite": "Предпочитани:",
|
||||||
"notifications.column_settings.follow": "Нови последователи:",
|
"notifications.column_settings.follow": "Нови последователи:",
|
||||||
"notifications.column_settings.mention": "Споменавания:",
|
"notifications.column_settings.mention": "Споменавания:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Споделяния:",
|
"notifications.column_settings.reblog": "Споделяния:",
|
||||||
"notifications.column_settings.show": "Покажи в колона",
|
"notifications.column_settings.show": "Покажи в колона",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Reporting",
|
"report.target": "Reporting",
|
||||||
"search.placeholder": "Търсене",
|
"search.placeholder": "Търсене",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Изтриване",
|
"status.delete": "Изтриване",
|
||||||
"status.favourite": "Предпочитани",
|
"status.favourite": "Предпочитани",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favorits:",
|
"notifications.column_settings.favourite": "Favorits:",
|
||||||
"notifications.column_settings.follow": "Nous seguidors:",
|
"notifications.column_settings.follow": "Nous seguidors:",
|
||||||
"notifications.column_settings.mention": "Mencions:",
|
"notifications.column_settings.mention": "Mencions:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boosts:",
|
"notifications.column_settings.reblog": "Boosts:",
|
||||||
"notifications.column_settings.show": "Mostrar en la columna",
|
"notifications.column_settings.show": "Mostrar en la columna",
|
||||||
"notifications.column_settings.sound": "Reproduïr so",
|
"notifications.column_settings.sound": "Reproduïr so",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Informes",
|
"report.target": "Informes",
|
||||||
"search.placeholder": "Cercar",
|
"search.placeholder": "Cercar",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
|
"status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
|
||||||
"status.delete": "Esborrar",
|
"status.delete": "Esborrar",
|
||||||
"status.favourite": "Favorit",
|
"status.favourite": "Favorit",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favorisierungen:",
|
"notifications.column_settings.favourite": "Favorisierungen:",
|
||||||
"notifications.column_settings.follow": "Neue Folgende:",
|
"notifications.column_settings.follow": "Neue Folgende:",
|
||||||
"notifications.column_settings.mention": "Erwähnungen:",
|
"notifications.column_settings.mention": "Erwähnungen:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Geteilte Beiträge:",
|
"notifications.column_settings.reblog": "Geteilte Beiträge:",
|
||||||
"notifications.column_settings.show": "In der Spalte anzeigen",
|
"notifications.column_settings.show": "In der Spalte anzeigen",
|
||||||
"notifications.column_settings.sound": "Ton abspielen",
|
"notifications.column_settings.sound": "Ton abspielen",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Melden",
|
"report.target": "Melden",
|
||||||
"search.placeholder": "Suche",
|
"search.placeholder": "Suche",
|
||||||
"search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
|
"search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Löschen",
|
"status.delete": "Löschen",
|
||||||
"status.favourite": "Favorisieren",
|
"status.favourite": "Favorisieren",
|
||||||
|
|
|
@ -889,6 +889,14 @@
|
||||||
"defaultMessage": "Play sound",
|
"defaultMessage": "Play sound",
|
||||||
"id": "notifications.column_settings.sound"
|
"id": "notifications.column_settings.sound"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Push notifications",
|
||||||
|
"id": "notifications.column_settings.push"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "This device",
|
||||||
|
"id": "notifications.column_settings.push_meta"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"defaultMessage": "New followers:",
|
"defaultMessage": "New followers:",
|
||||||
"id": "notifications.column_settings.follow"
|
"id": "notifications.column_settings.follow"
|
||||||
|
@ -964,6 +972,15 @@
|
||||||
],
|
],
|
||||||
"path": "app/javascript/mastodon/features/public_timeline/index.json"
|
"path": "app/javascript/mastodon/features/public_timeline/index.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"descriptors": [
|
||||||
|
{
|
||||||
|
"defaultMessage": "A look inside...",
|
||||||
|
"id": "standalone.public_title"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"path": "app/javascript/mastodon/features/standalone/public_timeline/index.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"descriptors": [
|
"descriptors": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -114,6 +114,8 @@
|
||||||
"notifications.column_settings.favourite": "Favourites:",
|
"notifications.column_settings.favourite": "Favourites:",
|
||||||
"notifications.column_settings.follow": "New followers:",
|
"notifications.column_settings.follow": "New followers:",
|
||||||
"notifications.column_settings.mention": "Mentions:",
|
"notifications.column_settings.mention": "Mentions:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boosts:",
|
"notifications.column_settings.reblog": "Boosts:",
|
||||||
"notifications.column_settings.show": "Show in column",
|
"notifications.column_settings.show": "Show in column",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -170,6 +172,7 @@
|
||||||
"settings.media_fullwidth": "Full-width media previews",
|
"settings.media_fullwidth": "Full-width media previews",
|
||||||
"settings.preferences": "User preferences",
|
"settings.preferences": "User preferences",
|
||||||
"settings.wide_view": "Wide view (Desktop mode only)",
|
"settings.wide_view": "Wide view (Desktop mode only)",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.collapse": "Collapse",
|
"status.collapse": "Collapse",
|
||||||
"status.delete": "Delete",
|
"status.delete": "Delete",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favoroj:",
|
"notifications.column_settings.favourite": "Favoroj:",
|
||||||
"notifications.column_settings.follow": "Novaj sekvantoj:",
|
"notifications.column_settings.follow": "Novaj sekvantoj:",
|
||||||
"notifications.column_settings.mention": "Mencioj:",
|
"notifications.column_settings.mention": "Mencioj:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Diskonigoj:",
|
"notifications.column_settings.reblog": "Diskonigoj:",
|
||||||
"notifications.column_settings.show": "Montri en kolono",
|
"notifications.column_settings.show": "Montri en kolono",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Reporting",
|
"report.target": "Reporting",
|
||||||
"search.placeholder": "Serĉi",
|
"search.placeholder": "Serĉi",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Forigi",
|
"status.delete": "Forigi",
|
||||||
"status.favourite": "Favori",
|
"status.favourite": "Favori",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favoritos:",
|
"notifications.column_settings.favourite": "Favoritos:",
|
||||||
"notifications.column_settings.follow": "Nuevos seguidores:",
|
"notifications.column_settings.follow": "Nuevos seguidores:",
|
||||||
"notifications.column_settings.mention": "Menciones:",
|
"notifications.column_settings.mention": "Menciones:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Retoots:",
|
"notifications.column_settings.reblog": "Retoots:",
|
||||||
"notifications.column_settings.show": "Mostrar en columna",
|
"notifications.column_settings.show": "Mostrar en columna",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Reporting",
|
"report.target": "Reporting",
|
||||||
"search.placeholder": "Buscar",
|
"search.placeholder": "Buscar",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Borrar",
|
"status.delete": "Borrar",
|
||||||
"status.favourite": "Favorito",
|
"status.favourite": "Favorito",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "پسندیدهها:",
|
"notifications.column_settings.favourite": "پسندیدهها:",
|
||||||
"notifications.column_settings.follow": "پیگیران تازه:",
|
"notifications.column_settings.follow": "پیگیران تازه:",
|
||||||
"notifications.column_settings.mention": "نامبردنها:",
|
"notifications.column_settings.mention": "نامبردنها:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "بازبوقها:",
|
"notifications.column_settings.reblog": "بازبوقها:",
|
||||||
"notifications.column_settings.show": "نمایش در ستون",
|
"notifications.column_settings.show": "نمایش در ستون",
|
||||||
"notifications.column_settings.sound": "پخش صدا",
|
"notifications.column_settings.sound": "پخش صدا",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "گزارشدادن",
|
"report.target": "گزارشدادن",
|
||||||
"search.placeholder": "جستجو",
|
"search.placeholder": "جستجو",
|
||||||
"search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
|
"search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "این نوشته را نمیشود بازبوقید",
|
"status.cannot_reblog": "این نوشته را نمیشود بازبوقید",
|
||||||
"status.delete": "پاککردن",
|
"status.delete": "پاککردن",
|
||||||
"status.favourite": "پسندیدن",
|
"status.favourite": "پسندیدن",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Tykkäyksiä:",
|
"notifications.column_settings.favourite": "Tykkäyksiä:",
|
||||||
"notifications.column_settings.follow": "Uusia seuraajia:",
|
"notifications.column_settings.follow": "Uusia seuraajia:",
|
||||||
"notifications.column_settings.mention": "Mainintoja:",
|
"notifications.column_settings.mention": "Mainintoja:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Buusteja:",
|
"notifications.column_settings.reblog": "Buusteja:",
|
||||||
"notifications.column_settings.show": "Näytä sarakkeessa",
|
"notifications.column_settings.show": "Näytä sarakkeessa",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Reporting",
|
"report.target": "Reporting",
|
||||||
"search.placeholder": "Hae",
|
"search.placeholder": "Hae",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Poista",
|
"status.delete": "Poista",
|
||||||
"status.favourite": "Tykkää",
|
"status.favourite": "Tykkää",
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
"column.favourites": "Favoris",
|
"column.favourites": "Favoris",
|
||||||
"column.follow_requests": "Demandes de suivi",
|
"column.follow_requests": "Demandes de suivi",
|
||||||
"column.home": "Accueil",
|
"column.home": "Accueil",
|
||||||
"column.mutes": "Comptes silencés",
|
"column.mutes": "Comptes masqués",
|
||||||
"column.notifications": "Notifications",
|
"column.notifications": "Notifications",
|
||||||
"column.public": "Fil public global",
|
"column.public": "Fil public global",
|
||||||
"column_back_button.label": "Retour",
|
"column_back_button.label": "Retour",
|
||||||
|
@ -52,9 +52,9 @@
|
||||||
"confirmations.delete.confirm": "Supprimer",
|
"confirmations.delete.confirm": "Supprimer",
|
||||||
"confirmations.delete.message": "Confirmez vous la suppression de ce pouet ?",
|
"confirmations.delete.message": "Confirmez vous la suppression de ce pouet ?",
|
||||||
"confirmations.domain_block.confirm": "Masquer le domaine entier",
|
"confirmations.domain_block.confirm": "Masquer le domaine entier",
|
||||||
"confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou silenciations ciblés sont suffisants et préférables.",
|
"confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables.",
|
||||||
"confirmations.mute.confirm": "Silencer",
|
"confirmations.mute.confirm": "Masquer",
|
||||||
"confirmations.mute.message": "Confirmez vous la silenciation {name} ?",
|
"confirmations.mute.message": "Confirmez vous le masquage de {name} ?",
|
||||||
"emoji_button.activity": "Activités",
|
"emoji_button.activity": "Activités",
|
||||||
"emoji_button.flags": "Drapeaux",
|
"emoji_button.flags": "Drapeaux",
|
||||||
"emoji_button.food": "Boire et manger",
|
"emoji_button.food": "Boire et manger",
|
||||||
|
@ -96,7 +96,7 @@
|
||||||
"navigation_bar.follow_requests": "Demandes de suivi",
|
"navigation_bar.follow_requests": "Demandes de suivi",
|
||||||
"navigation_bar.info": "Plus d’informations",
|
"navigation_bar.info": "Plus d’informations",
|
||||||
"navigation_bar.logout": "Déconnexion",
|
"navigation_bar.logout": "Déconnexion",
|
||||||
"navigation_bar.mutes": "Comptes silencés",
|
"navigation_bar.mutes": "Comptes masqués",
|
||||||
"navigation_bar.preferences": "Préférences",
|
"navigation_bar.preferences": "Préférences",
|
||||||
"navigation_bar.public_timeline": "Fil public global",
|
"navigation_bar.public_timeline": "Fil public global",
|
||||||
"notification.favourite": "{name} a ajouté à ses favoris :",
|
"notification.favourite": "{name} a ajouté à ses favoris :",
|
||||||
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favoris :",
|
"notifications.column_settings.favourite": "Favoris :",
|
||||||
"notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s :",
|
"notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s :",
|
||||||
"notifications.column_settings.mention": "Mentions :",
|
"notifications.column_settings.mention": "Mentions :",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Partages :",
|
"notifications.column_settings.reblog": "Partages :",
|
||||||
"notifications.column_settings.show": "Afficher dans la colonne",
|
"notifications.column_settings.show": "Afficher dans la colonne",
|
||||||
"notifications.column_settings.sound": "Émettre un son",
|
"notifications.column_settings.sound": "Émettre un son",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Signalement",
|
"report.target": "Signalement",
|
||||||
"search.placeholder": "Rechercher",
|
"search.placeholder": "Rechercher",
|
||||||
"search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
|
"search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Cette publication ne peut être boostée",
|
"status.cannot_reblog": "Cette publication ne peut être boostée",
|
||||||
"status.delete": "Effacer",
|
"status.delete": "Effacer",
|
||||||
"status.favourite": "Ajouter aux favoris",
|
"status.favourite": "Ajouter aux favoris",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "מחובבים:",
|
"notifications.column_settings.favourite": "מחובבים:",
|
||||||
"notifications.column_settings.follow": "עוקבים חדשים:",
|
"notifications.column_settings.follow": "עוקבים חדשים:",
|
||||||
"notifications.column_settings.mention": "פניות:",
|
"notifications.column_settings.mention": "פניות:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "הדהודים:",
|
"notifications.column_settings.reblog": "הדהודים:",
|
||||||
"notifications.column_settings.show": "הצגה בטור",
|
"notifications.column_settings.show": "הצגה בטור",
|
||||||
"notifications.column_settings.sound": "שמע מופעל",
|
"notifications.column_settings.sound": "שמע מופעל",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "דיווח",
|
"report.target": "דיווח",
|
||||||
"search.placeholder": "חיפוש",
|
"search.placeholder": "חיפוש",
|
||||||
"search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
|
"search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "לא ניתן להדהד הודעה זו",
|
"status.cannot_reblog": "לא ניתן להדהד הודעה זו",
|
||||||
"status.delete": "מחיקה",
|
"status.delete": "מחיקה",
|
||||||
"status.favourite": "חיבוב",
|
"status.favourite": "חיבוב",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favoriti:",
|
"notifications.column_settings.favourite": "Favoriti:",
|
||||||
"notifications.column_settings.follow": "Novi sljedbenici:",
|
"notifications.column_settings.follow": "Novi sljedbenici:",
|
||||||
"notifications.column_settings.mention": "Spominjanja:",
|
"notifications.column_settings.mention": "Spominjanja:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boosts:",
|
"notifications.column_settings.reblog": "Boosts:",
|
||||||
"notifications.column_settings.show": "Prikaži u stupcu",
|
"notifications.column_settings.show": "Prikaži u stupcu",
|
||||||
"notifications.column_settings.sound": "Sviraj zvuk",
|
"notifications.column_settings.sound": "Sviraj zvuk",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Prijavljivanje",
|
"report.target": "Prijavljivanje",
|
||||||
"search.placeholder": "Traži",
|
"search.placeholder": "Traži",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Ovaj post ne može biti podignut",
|
"status.cannot_reblog": "Ovaj post ne može biti podignut",
|
||||||
"status.delete": "Obriši",
|
"status.delete": "Obriši",
|
||||||
"status.favourite": "Označi omiljenim",
|
"status.favourite": "Označi omiljenim",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favourites:",
|
"notifications.column_settings.favourite": "Favourites:",
|
||||||
"notifications.column_settings.follow": "New followers:",
|
"notifications.column_settings.follow": "New followers:",
|
||||||
"notifications.column_settings.mention": "Mentions:",
|
"notifications.column_settings.mention": "Mentions:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boosts:",
|
"notifications.column_settings.reblog": "Boosts:",
|
||||||
"notifications.column_settings.show": "Show in column",
|
"notifications.column_settings.show": "Show in column",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Reporting",
|
"report.target": "Reporting",
|
||||||
"search.placeholder": "Keresés",
|
"search.placeholder": "Keresés",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Törlés",
|
"status.delete": "Törlés",
|
||||||
"status.favourite": "Kedvenc",
|
"status.favourite": "Kedvenc",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favorit:",
|
"notifications.column_settings.favourite": "Favorit:",
|
||||||
"notifications.column_settings.follow": "Pengikut baru:",
|
"notifications.column_settings.follow": "Pengikut baru:",
|
||||||
"notifications.column_settings.mention": "Balasan:",
|
"notifications.column_settings.mention": "Balasan:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boost:",
|
"notifications.column_settings.reblog": "Boost:",
|
||||||
"notifications.column_settings.show": "Tampilkan dalam kolom",
|
"notifications.column_settings.show": "Tampilkan dalam kolom",
|
||||||
"notifications.column_settings.sound": "Mainkan suara",
|
"notifications.column_settings.sound": "Mainkan suara",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Melaporkan",
|
"report.target": "Melaporkan",
|
||||||
"search.placeholder": "Pencarian",
|
"search.placeholder": "Pencarian",
|
||||||
"search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
|
"search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Hapus",
|
"status.delete": "Hapus",
|
||||||
"status.favourite": "Difavoritkan",
|
"status.favourite": "Difavoritkan",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favorati:",
|
"notifications.column_settings.favourite": "Favorati:",
|
||||||
"notifications.column_settings.follow": "Nova sequanti:",
|
"notifications.column_settings.follow": "Nova sequanti:",
|
||||||
"notifications.column_settings.mention": "Mencioni:",
|
"notifications.column_settings.mention": "Mencioni:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Repeti:",
|
"notifications.column_settings.reblog": "Repeti:",
|
||||||
"notifications.column_settings.show": "Montrar en kolumno",
|
"notifications.column_settings.show": "Montrar en kolumno",
|
||||||
"notifications.column_settings.sound": "Plear sono",
|
"notifications.column_settings.sound": "Plear sono",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Denuncante",
|
"report.target": "Denuncante",
|
||||||
"search.placeholder": "Serchez",
|
"search.placeholder": "Serchez",
|
||||||
"search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
|
"search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Efacar",
|
"status.delete": "Efacar",
|
||||||
"status.favourite": "Favorizar",
|
"status.favourite": "Favorizar",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Apprezzati:",
|
"notifications.column_settings.favourite": "Apprezzati:",
|
||||||
"notifications.column_settings.follow": "Nuovi seguaci:",
|
"notifications.column_settings.follow": "Nuovi seguaci:",
|
||||||
"notifications.column_settings.mention": "Menzioni:",
|
"notifications.column_settings.mention": "Menzioni:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Post condivisi:",
|
"notifications.column_settings.reblog": "Post condivisi:",
|
||||||
"notifications.column_settings.show": "Mostra in colonna",
|
"notifications.column_settings.show": "Mostra in colonna",
|
||||||
"notifications.column_settings.sound": "Riproduci suono",
|
"notifications.column_settings.sound": "Riproduci suono",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Invio la segnalazione",
|
"report.target": "Invio la segnalazione",
|
||||||
"search.placeholder": "Cerca",
|
"search.placeholder": "Cerca",
|
||||||
"search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
|
"search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Elimina",
|
"status.delete": "Elimina",
|
||||||
"status.favourite": "Apprezzato",
|
"status.favourite": "Apprezzato",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "お気に入り",
|
"notifications.column_settings.favourite": "お気に入り",
|
||||||
"notifications.column_settings.follow": "新しいフォロワー",
|
"notifications.column_settings.follow": "新しいフォロワー",
|
||||||
"notifications.column_settings.mention": "返信",
|
"notifications.column_settings.mention": "返信",
|
||||||
|
"notifications.column_settings.push": "プッシュ通知",
|
||||||
|
"notifications.column_settings.push_meta": "このデバイス",
|
||||||
"notifications.column_settings.reblog": "ブースト",
|
"notifications.column_settings.reblog": "ブースト",
|
||||||
"notifications.column_settings.show": "カラムに表示",
|
"notifications.column_settings.show": "カラムに表示",
|
||||||
"notifications.column_settings.sound": "通知音を再生",
|
"notifications.column_settings.sound": "通知音を再生",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "問題のユーザー",
|
"report.target": "問題のユーザー",
|
||||||
"search.placeholder": "検索",
|
"search.placeholder": "検索",
|
||||||
"search_results.total": "{count, number}件の結果",
|
"search_results.total": "{count, number}件の結果",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "この投稿はブーストできません",
|
"status.cannot_reblog": "この投稿はブーストできません",
|
||||||
"status.delete": "削除",
|
"status.delete": "削除",
|
||||||
"status.favourite": "お気に入り",
|
"status.favourite": "お気に入り",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "즐겨찾기",
|
"notifications.column_settings.favourite": "즐겨찾기",
|
||||||
"notifications.column_settings.follow": "새 팔로워",
|
"notifications.column_settings.follow": "새 팔로워",
|
||||||
"notifications.column_settings.mention": "답글",
|
"notifications.column_settings.mention": "답글",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "부스트",
|
"notifications.column_settings.reblog": "부스트",
|
||||||
"notifications.column_settings.show": "컬럼에 표시",
|
"notifications.column_settings.show": "컬럼에 표시",
|
||||||
"notifications.column_settings.sound": "효과음 재생",
|
"notifications.column_settings.sound": "효과음 재생",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "문제가 된 사용자",
|
"report.target": "문제가 된 사용자",
|
||||||
"search.placeholder": "검색",
|
"search.placeholder": "검색",
|
||||||
"search_results.total": "{count, number}건의 결과",
|
"search_results.total": "{count, number}건의 결과",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
|
"status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
|
||||||
"status.delete": "삭제",
|
"status.delete": "삭제",
|
||||||
"status.favourite": "즐겨찾기",
|
"status.favourite": "즐겨찾기",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favorieten:",
|
"notifications.column_settings.favourite": "Favorieten:",
|
||||||
"notifications.column_settings.follow": "Nieuwe volgers:",
|
"notifications.column_settings.follow": "Nieuwe volgers:",
|
||||||
"notifications.column_settings.mention": "Vermeldingen:",
|
"notifications.column_settings.mention": "Vermeldingen:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boosts:",
|
"notifications.column_settings.reblog": "Boosts:",
|
||||||
"notifications.column_settings.show": "In kolom tonen",
|
"notifications.column_settings.show": "In kolom tonen",
|
||||||
"notifications.column_settings.sound": "Geluid afspelen",
|
"notifications.column_settings.sound": "Geluid afspelen",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Rapporteren van",
|
"report.target": "Rapporteren van",
|
||||||
"search.placeholder": "Zoeken",
|
"search.placeholder": "Zoeken",
|
||||||
"search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
|
"search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Deze toot kan niet geboost worden",
|
"status.cannot_reblog": "Deze toot kan niet geboost worden",
|
||||||
"status.delete": "Verwijderen",
|
"status.delete": "Verwijderen",
|
||||||
"status.favourite": "Favoriet",
|
"status.favourite": "Favoriet",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Likt:",
|
"notifications.column_settings.favourite": "Likt:",
|
||||||
"notifications.column_settings.follow": "Nye følgere:",
|
"notifications.column_settings.follow": "Nye følgere:",
|
||||||
"notifications.column_settings.mention": "Nevnt:",
|
"notifications.column_settings.mention": "Nevnt:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Fremhevet:",
|
"notifications.column_settings.reblog": "Fremhevet:",
|
||||||
"notifications.column_settings.show": "Vis i kolonne",
|
"notifications.column_settings.show": "Vis i kolonne",
|
||||||
"notifications.column_settings.sound": "Spill lyd",
|
"notifications.column_settings.sound": "Spill lyd",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Rapporterer",
|
"report.target": "Rapporterer",
|
||||||
"search.placeholder": "Søk",
|
"search.placeholder": "Søk",
|
||||||
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
|
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Denne posten kan ikke fremheves",
|
"status.cannot_reblog": "Denne posten kan ikke fremheves",
|
||||||
"status.delete": "Slett",
|
"status.delete": "Slett",
|
||||||
"status.favourite": "Lik",
|
"status.favourite": "Lik",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favorits :",
|
"notifications.column_settings.favourite": "Favorits :",
|
||||||
"notifications.column_settings.follow": "Nòus seguidors :",
|
"notifications.column_settings.follow": "Nòus seguidors :",
|
||||||
"notifications.column_settings.mention": "Mencions :",
|
"notifications.column_settings.mention": "Mencions :",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Partatges :",
|
"notifications.column_settings.reblog": "Partatges :",
|
||||||
"notifications.column_settings.show": "Mostrar dins la colomna",
|
"notifications.column_settings.show": "Mostrar dins la colomna",
|
||||||
"notifications.column_settings.sound": "Emetre un son",
|
"notifications.column_settings.sound": "Emetre un son",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Senhalar {target}",
|
"report.target": "Senhalar {target}",
|
||||||
"search.placeholder": "Recercar",
|
"search.placeholder": "Recercar",
|
||||||
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
|
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
|
"status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
|
||||||
"status.delete": "Escafar",
|
"status.delete": "Escafar",
|
||||||
"status.favourite": "Apondre als favorits",
|
"status.favourite": "Apondre als favorits",
|
||||||
|
|
|
@ -3,10 +3,10 @@
|
||||||
"account.block_domain": "Blokuj wszystko z {domain}",
|
"account.block_domain": "Blokuj wszystko z {domain}",
|
||||||
"account.disclaimer": "Ten użytkownik pochodzi z innej instancji. Ta liczba może być większa.",
|
"account.disclaimer": "Ten użytkownik pochodzi z innej instancji. Ta liczba może być większa.",
|
||||||
"account.edit_profile": "Edytuj profil",
|
"account.edit_profile": "Edytuj profil",
|
||||||
"account.follow": "Obserwuj",
|
"account.follow": "Śledź",
|
||||||
"account.followers": "Obserwujący",
|
"account.followers": "Śledzący",
|
||||||
"account.follows": "Obserwacje",
|
"account.follows": "Śledzeni",
|
||||||
"account.follows_you": "Obserwuje cię",
|
"account.follows_you": "Śledzi Cię",
|
||||||
"account.media": "Media",
|
"account.media": "Media",
|
||||||
"account.mention": "Wspomnij o @{name}",
|
"account.mention": "Wspomnij o @{name}",
|
||||||
"account.mute": "Wycisz @{name}",
|
"account.mute": "Wycisz @{name}",
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
"account.requested": "Oczekująca prośba",
|
"account.requested": "Oczekująca prośba",
|
||||||
"account.unblock": "Odblokuj @{name}",
|
"account.unblock": "Odblokuj @{name}",
|
||||||
"account.unblock_domain": "Odblokuj domenę {domain}",
|
"account.unblock_domain": "Odblokuj domenę {domain}",
|
||||||
"account.unfollow": "Przestań obserwować",
|
"account.unfollow": "Przestań śledzić",
|
||||||
"account.unmute": "Cofnij wyciszenie @{name}",
|
"account.unmute": "Cofnij wyciszenie @{name}",
|
||||||
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
|
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
|
||||||
"bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
|
"bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
"column.blocks": "Zablokowani użytkownicy",
|
"column.blocks": "Zablokowani użytkownicy",
|
||||||
"column.community": "Lokalna oś czasu",
|
"column.community": "Lokalna oś czasu",
|
||||||
"column.favourites": "Ulubione",
|
"column.favourites": "Ulubione",
|
||||||
"column.follow_requests": "Prośby o obserwację",
|
"column.follow_requests": "Prośby o śledzenie",
|
||||||
"column.home": "Strona główna",
|
"column.home": "Strona główna",
|
||||||
"column.mutes": "Wyciszeni użytkownicy",
|
"column.mutes": "Wyciszeni użytkownicy",
|
||||||
"column.notifications": "Powiadomienia",
|
"column.notifications": "Powiadomienia",
|
||||||
|
@ -37,9 +37,9 @@
|
||||||
"column_header.unpin": "Cofnij przypięcie",
|
"column_header.unpin": "Cofnij przypięcie",
|
||||||
"column_subheading.navigation": "Nawigacja",
|
"column_subheading.navigation": "Nawigacja",
|
||||||
"column_subheading.settings": "Ustawienia",
|
"column_subheading.settings": "Ustawienia",
|
||||||
"compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto cię obserwuje, może wyświetlać twoje posty przeznaczone tylko dla obserwujących.",
|
"compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje posty przeznaczone tylko dla śledzących.",
|
||||||
"compose_form.lock_disclaimer.lock": "zablokowane",
|
"compose_form.lock_disclaimer.lock": "zablokowane",
|
||||||
"compose_form.placeholder": "Co ci chodzi po głowie?",
|
"compose_form.placeholder": "Co Ci chodzi po głowie?",
|
||||||
"compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.",
|
"compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.",
|
||||||
"compose_form.publish": "Wyślij",
|
"compose_form.publish": "Wyślij",
|
||||||
"compose_form.publish_loud": "{publish}!",
|
"compose_form.publish_loud": "{publish}!",
|
||||||
|
@ -67,7 +67,7 @@
|
||||||
"emoji_button.travel": "Podróże i miejsca",
|
"emoji_button.travel": "Podróże i miejsca",
|
||||||
"empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
|
"empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
|
||||||
"empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
|
"empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
|
||||||
"empty_column.home": "Nie obserwujesz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć ciekawych ludzi.",
|
"empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
|
||||||
"empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
|
"empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
|
||||||
"empty_column.home.public_timeline": "publiczna oś czasu",
|
"empty_column.home.public_timeline": "publiczna oś czasu",
|
||||||
"empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.",
|
"empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.",
|
||||||
|
@ -93,32 +93,34 @@
|
||||||
"navigation_bar.community_timeline": "Lokalna oś czasu",
|
"navigation_bar.community_timeline": "Lokalna oś czasu",
|
||||||
"navigation_bar.edit_profile": "Edytuj profil",
|
"navigation_bar.edit_profile": "Edytuj profil",
|
||||||
"navigation_bar.favourites": "Ulubione",
|
"navigation_bar.favourites": "Ulubione",
|
||||||
"navigation_bar.follow_requests": "Prośby o obserwację",
|
"navigation_bar.follow_requests": "Prośby o śledzenie",
|
||||||
"navigation_bar.info": "Szczegółowe informacje",
|
"navigation_bar.info": "Szczegółowe informacje",
|
||||||
"navigation_bar.logout": "Wyloguj",
|
"navigation_bar.logout": "Wyloguj",
|
||||||
"navigation_bar.mutes": "Wyciszeni użytkownicy",
|
"navigation_bar.mutes": "Wyciszeni użytkownicy",
|
||||||
"navigation_bar.preferences": "Preferencje",
|
"navigation_bar.preferences": "Preferencje",
|
||||||
"navigation_bar.public_timeline": "Oś czasu federacji",
|
"navigation_bar.public_timeline": "Oś czasu federacji",
|
||||||
"notification.favourite": "{name} dodał twój status do ulubionych",
|
"notification.favourite": "{name} dodał Twój status do ulubionych",
|
||||||
"notification.follow": "{name} zaczął cię obserwować",
|
"notification.follow": "{name} zaczął Cię śledzić",
|
||||||
"notification.mention": "{name} wspomniał o tobie",
|
"notification.mention": "{name} wspomniał o tobie",
|
||||||
"notification.reblog": "{name} podbił twój status",
|
"notification.reblog": "{name} podbił Twój status",
|
||||||
"notifications.clear": "Wyczyść powiadomienia",
|
"notifications.clear": "Wyczyść powiadomienia",
|
||||||
"notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
|
"notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
|
||||||
"notifications.column_settings.alert": "Powiadomienia na pulpicie",
|
"notifications.column_settings.alert": "Powiadomienia na pulpicie",
|
||||||
"notifications.column_settings.favourite": "Ulubione:",
|
"notifications.column_settings.favourite": "Ulubione:",
|
||||||
"notifications.column_settings.follow": "Nowi obserwujący:",
|
"notifications.column_settings.follow": "Nowi śledzący:",
|
||||||
"notifications.column_settings.mention": "Wspomniali:",
|
"notifications.column_settings.mention": "Wspomniali:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Podbili:",
|
"notifications.column_settings.reblog": "Podbili:",
|
||||||
"notifications.column_settings.show": "Pokaż w kolumnie",
|
"notifications.column_settings.show": "Pokaż w kolumnie",
|
||||||
"notifications.column_settings.sound": "Odtwarzaj dźwięk",
|
"notifications.column_settings.sound": "Odtwarzaj dźwięk",
|
||||||
"onboarding.done": "Gotowe",
|
"onboarding.done": "Gotowe",
|
||||||
"onboarding.next": "Dalej",
|
"onboarding.next": "Dalej",
|
||||||
"onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy obserwowanych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.",
|
"onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy śledzonych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.",
|
||||||
"onboarding.page_four.home": "Główna oś czasu wyświetla publiczne wpisy.",
|
"onboarding.page_four.home": "Główna oś czasu wyświetla publiczne wpisy.",
|
||||||
"onboarding.page_four.notifications": "Kolumna powiadomień wyświetla, gdy ktoś dokonuje interakcji z tobą.",
|
"onboarding.page_four.notifications": "Kolumna powiadomień wyświetla, gdy ktoś dokonuje interakcji z tobą.",
|
||||||
"onboarding.page_one.federation": "Mastodon jest siecią niezależnych serwerów połączonych w jeden portal społecznościowy. Nazywamy te serwery instancjami.",
|
"onboarding.page_one.federation": "Mastodon jest siecią niezależnych serwerów połączonych w jeden portal społecznościowy. Nazywamy te serwery instancjami.",
|
||||||
"onboarding.page_one.handle": "Jesteś na domenie {domain}, więc twój pełny adres to {handle}",
|
"onboarding.page_one.handle": "Jesteś na domenie {domain}, więc Twój pełny adres to {handle}",
|
||||||
"onboarding.page_one.welcome": "Witamy w Mastodon!",
|
"onboarding.page_one.welcome": "Witamy w Mastodon!",
|
||||||
"onboarding.page_six.admin": "Administratorem tej instancji jest {admin}.",
|
"onboarding.page_six.admin": "Administratorem tej instancji jest {admin}.",
|
||||||
"onboarding.page_six.almost_done": "Prawie gotowe...",
|
"onboarding.page_six.almost_done": "Prawie gotowe...",
|
||||||
|
@ -135,8 +137,8 @@
|
||||||
"privacy.change": "Dostosuj widoczność postów",
|
"privacy.change": "Dostosuj widoczność postów",
|
||||||
"privacy.direct.long": "Widoczne tylko dla oznaczonych",
|
"privacy.direct.long": "Widoczne tylko dla oznaczonych",
|
||||||
"privacy.direct.short": "Bezpośrednio",
|
"privacy.direct.short": "Bezpośrednio",
|
||||||
"privacy.private.long": "Widoczne tylko dla obserwujących",
|
"privacy.private.long": "Widoczne tylko dla śledzących",
|
||||||
"privacy.private.short": "Tylko obserwujący",
|
"privacy.private.short": "Tylko śledzący",
|
||||||
"privacy.public.long": "Widoczne na publicznych osiach czasu",
|
"privacy.public.long": "Widoczne na publicznych osiach czasu",
|
||||||
"privacy.public.short": "Publiczne",
|
"privacy.public.short": "Publiczne",
|
||||||
"privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
|
"privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Zgłaszanie {target}",
|
"report.target": "Zgłaszanie {target}",
|
||||||
"search.placeholder": "Szukaj",
|
"search.placeholder": "Szukaj",
|
||||||
"search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
|
"search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Ten post nie może zostać podbity",
|
"status.cannot_reblog": "Ten post nie może zostać podbity",
|
||||||
"status.delete": "Usuń",
|
"status.delete": "Usuń",
|
||||||
"status.favourite": "Ulubione",
|
"status.favourite": "Ulubione",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favoritos:",
|
"notifications.column_settings.favourite": "Favoritos:",
|
||||||
"notifications.column_settings.follow": "Novos seguidores:",
|
"notifications.column_settings.follow": "Novos seguidores:",
|
||||||
"notifications.column_settings.mention": "Menções:",
|
"notifications.column_settings.mention": "Menções:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Partilhas:",
|
"notifications.column_settings.reblog": "Partilhas:",
|
||||||
"notifications.column_settings.show": "Mostrar nas colunas",
|
"notifications.column_settings.show": "Mostrar nas colunas",
|
||||||
"notifications.column_settings.sound": "Reproduzir som",
|
"notifications.column_settings.sound": "Reproduzir som",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Denunciar",
|
"report.target": "Denunciar",
|
||||||
"search.placeholder": "Pesquisar",
|
"search.placeholder": "Pesquisar",
|
||||||
"search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
|
"search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Eliminar",
|
"status.delete": "Eliminar",
|
||||||
"status.favourite": "Adicionar aos favoritos",
|
"status.favourite": "Adicionar aos favoritos",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favoritos:",
|
"notifications.column_settings.favourite": "Favoritos:",
|
||||||
"notifications.column_settings.follow": "Novos seguidores:",
|
"notifications.column_settings.follow": "Novos seguidores:",
|
||||||
"notifications.column_settings.mention": "Menções:",
|
"notifications.column_settings.mention": "Menções:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Partilhas:",
|
"notifications.column_settings.reblog": "Partilhas:",
|
||||||
"notifications.column_settings.show": "Mostrar nas colunas",
|
"notifications.column_settings.show": "Mostrar nas colunas",
|
||||||
"notifications.column_settings.sound": "Reproduzir som",
|
"notifications.column_settings.sound": "Reproduzir som",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Denunciar",
|
"report.target": "Denunciar",
|
||||||
"search.placeholder": "Pesquisar",
|
"search.placeholder": "Pesquisar",
|
||||||
"search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
|
"search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Eliminar",
|
"status.delete": "Eliminar",
|
||||||
"status.favourite": "Adicionar aos favoritos",
|
"status.favourite": "Adicionar aos favoritos",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Нравится:",
|
"notifications.column_settings.favourite": "Нравится:",
|
||||||
"notifications.column_settings.follow": "Новые подписчики:",
|
"notifications.column_settings.follow": "Новые подписчики:",
|
||||||
"notifications.column_settings.mention": "Упоминания:",
|
"notifications.column_settings.mention": "Упоминания:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Продвижения:",
|
"notifications.column_settings.reblog": "Продвижения:",
|
||||||
"notifications.column_settings.show": "Показывать в колонке",
|
"notifications.column_settings.show": "Показывать в колонке",
|
||||||
"notifications.column_settings.sound": "Проигрывать звук",
|
"notifications.column_settings.sound": "Проигрывать звук",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Жалуемся на",
|
"report.target": "Жалуемся на",
|
||||||
"search.placeholder": "Поиск",
|
"search.placeholder": "Поиск",
|
||||||
"search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
|
"search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Этот статус не может быть продвинут",
|
"status.cannot_reblog": "Этот статус не может быть продвинут",
|
||||||
"status.delete": "Удалить",
|
"status.delete": "Удалить",
|
||||||
"status.favourite": "Нравится",
|
"status.favourite": "Нравится",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favourites:",
|
"notifications.column_settings.favourite": "Favourites:",
|
||||||
"notifications.column_settings.follow": "New followers:",
|
"notifications.column_settings.follow": "New followers:",
|
||||||
"notifications.column_settings.mention": "Mentions:",
|
"notifications.column_settings.mention": "Mentions:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boosts:",
|
"notifications.column_settings.reblog": "Boosts:",
|
||||||
"notifications.column_settings.show": "Show in column",
|
"notifications.column_settings.show": "Show in column",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Reporting",
|
"report.target": "Reporting",
|
||||||
"search.placeholder": "Search",
|
"search.placeholder": "Search",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.delete": "Delete",
|
"status.delete": "Delete",
|
||||||
"status.favourite": "Favourite",
|
"status.favourite": "Favourite",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Favoriler:",
|
"notifications.column_settings.favourite": "Favoriler:",
|
||||||
"notifications.column_settings.follow": "Yeni takipçiler:",
|
"notifications.column_settings.follow": "Yeni takipçiler:",
|
||||||
"notifications.column_settings.mention": "Bahsedilenler:",
|
"notifications.column_settings.mention": "Bahsedilenler:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Boost’lar:",
|
"notifications.column_settings.reblog": "Boost’lar:",
|
||||||
"notifications.column_settings.show": "Bildirimlerde göster",
|
"notifications.column_settings.show": "Bildirimlerde göster",
|
||||||
"notifications.column_settings.sound": "Ses çal",
|
"notifications.column_settings.sound": "Ses çal",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Raporlama",
|
"report.target": "Raporlama",
|
||||||
"search.placeholder": "Ara",
|
"search.placeholder": "Ara",
|
||||||
"search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}",
|
"search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Bu gönderi boost edilemez",
|
"status.cannot_reblog": "Bu gönderi boost edilemez",
|
||||||
"status.delete": "Sil",
|
"status.delete": "Sil",
|
||||||
"status.favourite": "Favorilere ekle",
|
"status.favourite": "Favorilere ekle",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "Вподобане:",
|
"notifications.column_settings.favourite": "Вподобане:",
|
||||||
"notifications.column_settings.follow": "Нові підписники:",
|
"notifications.column_settings.follow": "Нові підписники:",
|
||||||
"notifications.column_settings.mention": "Сповіщення:",
|
"notifications.column_settings.mention": "Сповіщення:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "Передмухи:",
|
"notifications.column_settings.reblog": "Передмухи:",
|
||||||
"notifications.column_settings.show": "Показати в колонці",
|
"notifications.column_settings.show": "Показати в колонці",
|
||||||
"notifications.column_settings.sound": "Відтворювати звук",
|
"notifications.column_settings.sound": "Відтворювати звук",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Скаржимося на",
|
"report.target": "Скаржимося на",
|
||||||
"search.placeholder": "Пошук",
|
"search.placeholder": "Пошук",
|
||||||
"search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
|
"search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "Цей допис не може бути передмухнутий",
|
"status.cannot_reblog": "Цей допис не може бути передмухнутий",
|
||||||
"status.delete": "Видалити",
|
"status.delete": "Видалити",
|
||||||
"status.favourite": "Подобається",
|
"status.favourite": "Подобається",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "你的嘟文被赞:",
|
"notifications.column_settings.favourite": "你的嘟文被赞:",
|
||||||
"notifications.column_settings.follow": "关注你:",
|
"notifications.column_settings.follow": "关注你:",
|
||||||
"notifications.column_settings.mention": "提及你:",
|
"notifications.column_settings.mention": "提及你:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "你的嘟文被转嘟:",
|
"notifications.column_settings.reblog": "你的嘟文被转嘟:",
|
||||||
"notifications.column_settings.show": "在通知栏显示",
|
"notifications.column_settings.show": "在通知栏显示",
|
||||||
"notifications.column_settings.sound": "播放音效",
|
"notifications.column_settings.sound": "播放音效",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "Reporting",
|
"report.target": "Reporting",
|
||||||
"search.placeholder": "搜索",
|
"search.placeholder": "搜索",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "没法转嘟这条嘟文啦……",
|
"status.cannot_reblog": "没法转嘟这条嘟文啦……",
|
||||||
"status.delete": "删除",
|
"status.delete": "删除",
|
||||||
"status.favourite": "赞",
|
"status.favourite": "赞",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "喜歡你的文章:",
|
"notifications.column_settings.favourite": "喜歡你的文章:",
|
||||||
"notifications.column_settings.follow": "關注你:",
|
"notifications.column_settings.follow": "關注你:",
|
||||||
"notifications.column_settings.mention": "提及你:",
|
"notifications.column_settings.mention": "提及你:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "轉推你的文章:",
|
"notifications.column_settings.reblog": "轉推你的文章:",
|
||||||
"notifications.column_settings.show": "在通知欄顯示",
|
"notifications.column_settings.show": "在通知欄顯示",
|
||||||
"notifications.column_settings.sound": "播放音效",
|
"notifications.column_settings.sound": "播放音效",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "舉報",
|
"report.target": "舉報",
|
||||||
"search.placeholder": "搜尋",
|
"search.placeholder": "搜尋",
|
||||||
"search_results.total": "{count, number} 項結果",
|
"search_results.total": "{count, number} 項結果",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "這篇文章無法被轉推",
|
"status.cannot_reblog": "這篇文章無法被轉推",
|
||||||
"status.delete": "刪除",
|
"status.delete": "刪除",
|
||||||
"status.favourite": "喜歡",
|
"status.favourite": "喜歡",
|
||||||
|
|
|
@ -109,6 +109,8 @@
|
||||||
"notifications.column_settings.favourite": "最愛:",
|
"notifications.column_settings.favourite": "最愛:",
|
||||||
"notifications.column_settings.follow": "新的關注者:",
|
"notifications.column_settings.follow": "新的關注者:",
|
||||||
"notifications.column_settings.mention": "提到:",
|
"notifications.column_settings.mention": "提到:",
|
||||||
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.push_meta": "This device",
|
||||||
"notifications.column_settings.reblog": "轉推:",
|
"notifications.column_settings.reblog": "轉推:",
|
||||||
"notifications.column_settings.show": "顯示在欄位中",
|
"notifications.column_settings.show": "顯示在欄位中",
|
||||||
"notifications.column_settings.sound": "播放音效",
|
"notifications.column_settings.sound": "播放音效",
|
||||||
|
@ -147,6 +149,7 @@
|
||||||
"report.target": "通報中",
|
"report.target": "通報中",
|
||||||
"search.placeholder": "搜尋",
|
"search.placeholder": "搜尋",
|
||||||
"search_results.total": "{count, number} 項結果",
|
"search_results.total": "{count, number} 項結果",
|
||||||
|
"standalone.public_title": "A look inside...",
|
||||||
"status.cannot_reblog": "此貼文無法轉推",
|
"status.cannot_reblog": "此貼文無法轉推",
|
||||||
"status.delete": "刪除",
|
"status.delete": "刪除",
|
||||||
"status.favourite": "喜愛",
|
"status.favourite": "喜愛",
|
||||||
|
|
|
@ -1,12 +1,6 @@
|
||||||
const perf = require('./performance');
|
import ready from './ready';
|
||||||
|
|
||||||
function onDomContentLoaded(callback) {
|
const perf = require('./performance');
|
||||||
if (document.readyState !== 'loading') {
|
|
||||||
callback();
|
|
||||||
} else {
|
|
||||||
document.addEventListener('DOMContentLoaded', callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
perf.start('main()');
|
perf.start('main()');
|
||||||
|
@ -24,11 +18,19 @@ function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onDomContentLoaded(() => {
|
ready(() => {
|
||||||
const mountNode = document.getElementById('mastodon');
|
const mountNode = document.getElementById('mastodon');
|
||||||
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
||||||
|
|
||||||
ReactDOM.render(<Mastodon {...props} />, mountNode);
|
ReactDOM.render(<Mastodon {...props} />, mountNode);
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
// avoid offline in dev mode because it's harder to debug
|
||||||
|
const OfflinePluginRuntime = require('offline-plugin/runtime');
|
||||||
|
const WebPushSubscription = require('./web_push_subscription');
|
||||||
|
|
||||||
|
OfflinePluginRuntime.install();
|
||||||
|
WebPushSubscription.register();
|
||||||
|
}
|
||||||
perf.stop('main()');
|
perf.stop('main()');
|
||||||
|
|
||||||
// remember the initial URL
|
// remember the initial URL
|
||||||
|
|
7
app/javascript/mastodon/ready.js
Normal file
7
app/javascript/mastodon/ready.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export default function ready(loaded) {
|
||||||
|
if (['interactive', 'complete'].includes(document.readyState)) {
|
||||||
|
loaded();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', loaded);
|
||||||
|
}
|
||||||
|
}
|
|
@ -126,7 +126,7 @@ const insertSuggestion = (state, position, token, completion) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertEmoji = (state, position, emojiData) => {
|
const insertEmoji = (state, position, emojiData) => {
|
||||||
const emoji = emojiData.shortname;
|
const emoji = String.fromCodePoint(parseInt(emojiData.unicode, 16));
|
||||||
|
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
|
map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import statuses from './statuses';
|
||||||
import relationships from './relationships';
|
import relationships from './relationships';
|
||||||
import settings from './settings';
|
import settings from './settings';
|
||||||
import local_settings from '../../glitch/reducers/local_settings';
|
import local_settings from '../../glitch/reducers/local_settings';
|
||||||
|
import push_notifications from './push_notifications';
|
||||||
import status_lists from './status_lists';
|
import status_lists from './status_lists';
|
||||||
import cards from './cards';
|
import cards from './cards';
|
||||||
import reports from './reports';
|
import reports from './reports';
|
||||||
|
@ -33,7 +34,11 @@ const reducers = {
|
||||||
statuses,
|
statuses,
|
||||||
relationships,
|
relationships,
|
||||||
settings,
|
settings,
|
||||||
|
<<<<<<< HEAD
|
||||||
local_settings,
|
local_settings,
|
||||||
|
=======
|
||||||
|
push_notifications,
|
||||||
|
>>>>>>> upstream
|
||||||
cards,
|
cards,
|
||||||
reports,
|
reports,
|
||||||
contexts,
|
contexts,
|
||||||
|
|
51
app/javascript/mastodon/reducers/push_notifications.js
Normal file
51
app/javascript/mastodon/reducers/push_notifications.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { STORE_HYDRATE } from '../actions/store';
|
||||||
|
import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications';
|
||||||
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
|
const initialState = Immutable.Map({
|
||||||
|
subscription: null,
|
||||||
|
alerts: new Immutable.Map({
|
||||||
|
follow: false,
|
||||||
|
favourite: false,
|
||||||
|
reblog: false,
|
||||||
|
mention: false,
|
||||||
|
}),
|
||||||
|
isSubscribed: false,
|
||||||
|
browserSupport: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function push_subscriptions(state = initialState, action) {
|
||||||
|
switch(action.type) {
|
||||||
|
case STORE_HYDRATE: {
|
||||||
|
const push_subscription = action.state.get('push_subscription');
|
||||||
|
|
||||||
|
if (push_subscription) {
|
||||||
|
return state
|
||||||
|
.set('subscription', new Immutable.Map({
|
||||||
|
id: push_subscription.get('id'),
|
||||||
|
endpoint: push_subscription.get('endpoint'),
|
||||||
|
}))
|
||||||
|
.set('alerts', push_subscription.get('alerts') || initialState.get('alerts'))
|
||||||
|
.set('isSubscribed', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
case SET_SUBSCRIPTION:
|
||||||
|
return state
|
||||||
|
.set('subscription', new Immutable.Map({
|
||||||
|
id: action.subscription.id,
|
||||||
|
endpoint: action.subscription.endpoint,
|
||||||
|
}))
|
||||||
|
.set('alerts', new Immutable.Map(action.subscription.alerts))
|
||||||
|
.set('isSubscribed', true);
|
||||||
|
case SET_BROWSER_SUPPORT:
|
||||||
|
return state.set('browserSupport', action.value);
|
||||||
|
case CLEAR_SUBSCRIPTION:
|
||||||
|
return initialState;
|
||||||
|
case ALERTS_CHANGE:
|
||||||
|
return state.setIn(action.key, action.value);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
1
app/javascript/mastodon/service_worker/entry.js
Normal file
1
app/javascript/mastodon/service_worker/entry.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
import './web_push_notifications';
|
|
@ -0,0 +1,86 @@
|
||||||
|
const handlePush = (event) => {
|
||||||
|
const options = event.data.json();
|
||||||
|
|
||||||
|
options.body = options.data.nsfw || options.data.content;
|
||||||
|
options.image = options.image || undefined; // Null results in a network request (404)
|
||||||
|
options.timestamp = options.timestamp && new Date(options.timestamp);
|
||||||
|
|
||||||
|
const expandAction = options.data.actions.find(action => action.todo === 'expand');
|
||||||
|
|
||||||
|
if (expandAction) {
|
||||||
|
options.actions = [expandAction];
|
||||||
|
options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
|
||||||
|
|
||||||
|
options.data.hiddenImage = options.image;
|
||||||
|
options.image = undefined;
|
||||||
|
} else {
|
||||||
|
options.actions = options.data.actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(options.title, options));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneNotification = (notification) => {
|
||||||
|
const clone = { };
|
||||||
|
|
||||||
|
for(var k in notification) {
|
||||||
|
clone[k] = notification[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandNotification = (notification) => {
|
||||||
|
const nextNotification = cloneNotification(notification);
|
||||||
|
|
||||||
|
nextNotification.body = notification.data.content;
|
||||||
|
nextNotification.image = notification.data.hiddenImage;
|
||||||
|
nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
|
||||||
|
|
||||||
|
return self.registration.showNotification(nextNotification.title, nextNotification);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRequest = (notification, action) =>
|
||||||
|
fetch(action.action, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${notification.data.access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method: action.method,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeActionFromNotification = (notification, action) => {
|
||||||
|
const actions = notification.actions.filter(act => act.action !== action.action);
|
||||||
|
|
||||||
|
const nextNotification = cloneNotification(notification);
|
||||||
|
|
||||||
|
nextNotification.actions = actions;
|
||||||
|
|
||||||
|
return self.registration.showNotification(nextNotification.title, nextNotification);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = (event) => {
|
||||||
|
const reactToNotificationClick = new Promise((resolve, reject) => {
|
||||||
|
if (event.action) {
|
||||||
|
const action = event.notification.data.actions.find(({ action }) => action === event.action);
|
||||||
|
|
||||||
|
if (action.todo === 'expand') {
|
||||||
|
resolve(expandNotification(event.notification));
|
||||||
|
} else if (action.todo === 'request') {
|
||||||
|
resolve(makeRequest(event.notification, action)
|
||||||
|
.then(() => removeActionFromNotification(event.notification, action)));
|
||||||
|
} else {
|
||||||
|
reject(`Unknown action: ${action.todo}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.notification.close();
|
||||||
|
resolve(self.clients.openWindow(event.notification.data.url));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.waitUntil(reactToNotificationClick);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.addEventListener('push', handlePush);
|
||||||
|
self.addEventListener('notificationclick', handleNotificationClick);
|
109
app/javascript/mastodon/web_push_subscription.js
Normal file
109
app/javascript/mastodon/web_push_subscription.js
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { store } from './containers/mastodon';
|
||||||
|
import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications';
|
||||||
|
|
||||||
|
// Taken from https://www.npmjs.com/package/web-push
|
||||||
|
const urlBase64ToUint8Array = (base64String) => {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
|
const base64 = (base64String + padding)
|
||||||
|
.replace(/\-/g, '+')
|
||||||
|
.replace(/_/g, '/');
|
||||||
|
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; ++i) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
|
||||||
|
|
||||||
|
const getRegistration = () => navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
const getPushSubscription = (registration) =>
|
||||||
|
registration.pushManager.getSubscription()
|
||||||
|
.then(subscription => ({ registration, subscription }));
|
||||||
|
|
||||||
|
const subscribe = (registration) =>
|
||||||
|
registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribe = ({ registration, subscription }) =>
|
||||||
|
subscription ? subscription.unsubscribe().then(() => registration) : registration;
|
||||||
|
|
||||||
|
const sendSubscriptionToBackend = (subscription) =>
|
||||||
|
axios.post('/api/web/push_subscriptions', {
|
||||||
|
data: subscription,
|
||||||
|
}).then(response => response.data);
|
||||||
|
|
||||||
|
// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
|
||||||
|
const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
|
||||||
|
|
||||||
|
export function register () {
|
||||||
|
store.dispatch(setBrowserSupport(supportsPushNotifications));
|
||||||
|
|
||||||
|
if (supportsPushNotifications) {
|
||||||
|
if (!getApplicationServerKey()) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRegistration()
|
||||||
|
.then(getPushSubscription)
|
||||||
|
.then(({ registration, subscription }) => {
|
||||||
|
if (subscription !== null) {
|
||||||
|
// We have a subscription, check if it is still valid
|
||||||
|
const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
|
||||||
|
const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
|
||||||
|
const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']);
|
||||||
|
|
||||||
|
// If the VAPID public key did not change and the endpoint corresponds
|
||||||
|
// to the endpoint saved in the backend, the subscription is valid
|
||||||
|
if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
|
||||||
|
return subscription;
|
||||||
|
} else {
|
||||||
|
// Something went wrong, try to subscribe again
|
||||||
|
return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No subscription, try to subscribe
|
||||||
|
return subscribe(registration).then(sendSubscriptionToBackend);
|
||||||
|
})
|
||||||
|
.then(subscription => {
|
||||||
|
// If we got a PushSubscription (and not a subscription object from the backend)
|
||||||
|
// it means that the backend subscription is valid (and was set during hydration)
|
||||||
|
if (!(subscription instanceof PushSubscription)) {
|
||||||
|
store.dispatch(setSubscription(subscription));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error.code === 20 && error.name === 'AbortError') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
|
||||||
|
} else if (error.code === 5 && error.name === 'InvalidCharacterError') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear alerts and hide UI settings
|
||||||
|
store.dispatch(clearSubscription());
|
||||||
|
|
||||||
|
try {
|
||||||
|
getRegistration()
|
||||||
|
.then(getPushSubscription)
|
||||||
|
.then(unsubscribe);
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('Your browser does not support Web Push Notifications.');
|
||||||
|
}
|
||||||
|
}
|
24
app/javascript/packs/about.js
Normal file
24
app/javascript/packs/about.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import TimelineContainer from '../mastodon/containers/timeline_container';
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import loadPolyfills from '../mastodon/load_polyfills';
|
||||||
|
import ready from '../mastodon/ready';
|
||||||
|
|
||||||
|
require.context('../images/', true);
|
||||||
|
|
||||||
|
function loaded() {
|
||||||
|
const mountNode = document.getElementById('mastodon-timeline');
|
||||||
|
|
||||||
|
if (mountNode !== null) {
|
||||||
|
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
||||||
|
ReactDOM.render(<TimelineContainer {...props} />, mountNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
ready(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPolyfills().then(main).catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
|
@ -5,9 +5,7 @@ import emojify from '../mastodon/emoji';
|
||||||
import { getLocale } from '../mastodon/locales';
|
import { getLocale } from '../mastodon/locales';
|
||||||
import loadPolyfills from '../mastodon/load_polyfills';
|
import loadPolyfills from '../mastodon/load_polyfills';
|
||||||
import { processBio } from '../glitch/util/bio_metadata';
|
import { processBio } from '../glitch/util/bio_metadata';
|
||||||
import TimelineContainer from '../mastodon/containers/timeline_container';
|
import ready from '../mastodon/ready';
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
|
|
||||||
require.context('../images/', true);
|
require.context('../images/', true);
|
||||||
|
|
||||||
|
@ -40,21 +38,10 @@ function loaded() {
|
||||||
const datetime = new Date(content.getAttribute('datetime'));
|
const datetime = new Date(content.getAttribute('datetime'));
|
||||||
content.textContent = relativeFormat.format(datetime);;
|
content.textContent = relativeFormat.format(datetime);;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mountNode = document.getElementById('mastodon-timeline');
|
|
||||||
|
|
||||||
if (mountNode !== null) {
|
|
||||||
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
|
||||||
ReactDOM.render(<TimelineContainer {...props} />, mountNode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
if (['interactive', 'complete'].includes(document.readyState)) {
|
ready(loaded);
|
||||||
loaded();
|
|
||||||
} else {
|
|
||||||
document.addEventListener('DOMContentLoaded', loaded);
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate(document, '.video-player video', 'click', ({ target }) => {
|
delegate(document, '.video-player video', 'click', ({ target }) => {
|
||||||
if (target.paused) {
|
if (target.paused) {
|
||||||
|
|
|
@ -1554,6 +1554,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-swipeable-view-container > * {
|
.react-swipeable-view-container > * {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2007,6 +2010,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $ui-base-color;
|
color: $ui-base-color;
|
||||||
|
background: $simple-background-color;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
@ -2029,7 +2033,6 @@
|
||||||
|
|
||||||
.autosuggest-textarea__textarea {
|
.autosuggest-textarea__textarea {
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
background: $simple-background-color;
|
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
padding-right: 10px + 22px;
|
padding-right: 10px + 22px;
|
||||||
|
@ -2620,7 +2623,8 @@ button.icon-button.active i.fa-retweet {
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-toggle__label {
|
.setting-toggle__label,
|
||||||
|
.setting-meta__label {
|
||||||
color: $ui-primary-color;
|
color: $ui-primary-color;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
@ -2628,6 +2632,11 @@ button.icon-button.active i.fa-retweet {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-meta__label {
|
||||||
|
color: $ui-primary-color;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-column-indicator,
|
.empty-column-indicator,
|
||||||
.error-column {
|
.error-column {
|
||||||
color: lighten($ui-base-color, 20%);
|
color: lighten($ui-base-color, 20%);
|
||||||
|
@ -2968,6 +2977,7 @@ button.icon-button.active i.fa-retweet {
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
|
@ -3297,6 +3307,7 @@ button.icon-button.active i.fa-retweet {
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
.extended-video-player,
|
||||||
img,
|
img,
|
||||||
canvas,
|
canvas,
|
||||||
video {
|
video {
|
||||||
|
@ -3306,6 +3317,13 @@ button.icon-button.active i.fa-retweet {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.extended-video-player,
|
||||||
|
video {
|
||||||
|
display: flex;
|
||||||
|
width: 80vw;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -45,6 +45,10 @@ body.rtl {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-meta__label {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
.status__avatar {
|
.status__avatar {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
|
|
13
app/lib/activitypub/adapter.rb
Normal file
13
app/lib/activitypub/adapter.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||||
|
def self.default_key_transform
|
||||||
|
:camel_lower
|
||||||
|
end
|
||||||
|
|
||||||
|
def serializable_hash(options = nil)
|
||||||
|
options = serialization_options(options)
|
||||||
|
serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
|
||||||
|
self.class.transform_key_casing!(serialized_hash, instance_options)
|
||||||
|
end
|
||||||
|
end
|
69
app/lib/activitypub/tag_manager.rb
Normal file
69
app/lib/activitypub/tag_manager.rb
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'singleton'
|
||||||
|
|
||||||
|
class ActivityPub::TagManager
|
||||||
|
include Singleton
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
COLLECTIONS = {
|
||||||
|
public: 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def url_for(target)
|
||||||
|
return target.url if target.respond_to?(:local?) && !target.local?
|
||||||
|
|
||||||
|
case target.object_type
|
||||||
|
when :person
|
||||||
|
short_account_url(target)
|
||||||
|
when :note, :comment, :activity
|
||||||
|
short_account_status_url(target.account, target)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def uri_for(target)
|
||||||
|
return target.uri if target.respond_to?(:local?) && !target.local?
|
||||||
|
|
||||||
|
case target.object_type
|
||||||
|
when :person
|
||||||
|
account_url(target)
|
||||||
|
when :note, :comment, :activity
|
||||||
|
account_status_url(target.account, target)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Primary audience of a status
|
||||||
|
# Public statuses go out to primarily the public collection
|
||||||
|
# Unlisted and private statuses go out primarily to the followers collection
|
||||||
|
# Others go out only to the people they mention
|
||||||
|
def to(status)
|
||||||
|
case status.visibility
|
||||||
|
when 'public'
|
||||||
|
[COLLECTIONS[:public]]
|
||||||
|
when 'unlisted', 'private'
|
||||||
|
[account_followers_url(status.account)]
|
||||||
|
when 'direct'
|
||||||
|
status.mentions.map { |mention| uri_for(mention.account) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Secondary audience of a status
|
||||||
|
# Public statuses go out to followers as well
|
||||||
|
# Unlisted statuses go to the public as well
|
||||||
|
# Both of those and private statuses also go to the people mentioned in them
|
||||||
|
# Direct ones don't have a secondary audience
|
||||||
|
def cc(status)
|
||||||
|
cc = []
|
||||||
|
|
||||||
|
case status.visibility
|
||||||
|
when 'public'
|
||||||
|
cc << account_followers_url(status.account)
|
||||||
|
when 'unlisted'
|
||||||
|
cc << COLLECTIONS[:public]
|
||||||
|
end
|
||||||
|
|
||||||
|
cc.concat(status.mentions.map { |mention| uri_for(mention.account) }) unless status.direct_visibility?
|
||||||
|
|
||||||
|
cc
|
||||||
|
end
|
||||||
|
end
|
|
@ -99,7 +99,7 @@ class FeedManager
|
||||||
#return true if reggie === status.content || reggie === status.spoiler_text
|
#return true if reggie === status.content || reggie === status.spoiler_text
|
||||||
# extremely violent filtering code END
|
# extremely violent filtering code END
|
||||||
|
|
||||||
return true if status.reply? && status.in_reply_to_id.nil?
|
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
|
||||||
|
|
||||||
check_for_mutes = [status.account_id]
|
check_for_mutes = [status.account_id]
|
||||||
check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
|
check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
|
||||||
|
@ -126,12 +126,13 @@ class FeedManager
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_from_mentions?(status, receiver_id)
|
def filter_from_mentions?(status, receiver_id)
|
||||||
|
return true if receiver_id == status.account_id
|
||||||
|
|
||||||
check_for_blocks = [status.account_id]
|
check_for_blocks = [status.account_id]
|
||||||
check_for_blocks.concat(status.mentions.pluck(:account_id))
|
check_for_blocks.concat(status.mentions.pluck(:account_id))
|
||||||
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
|
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
|
||||||
|
|
||||||
should_filter = receiver_id == status.account_id # Filter if I'm mentioning myself
|
should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
|
||||||
should_filter ||= Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # or it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
|
|
||||||
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
|
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
|
||||||
|
|
||||||
should_filter
|
should_filter
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ProviderDiscovery < OEmbed::ProviderDiscovery
|
class ProviderDiscovery < OEmbed::ProviderDiscovery
|
||||||
extend HttpHelper
|
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def discover_provider(url, options = {})
|
def discover_provider(url, options = {})
|
||||||
res = http_client.get(url)
|
res = Request.new(:get, url).perform
|
||||||
format = options[:format]
|
format = options[:format]
|
||||||
|
|
||||||
raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
|
raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
|
||||||
|
|
70
app/lib/request.rb
Normal file
70
app/lib/request.rb
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Request
|
||||||
|
REQUEST_TARGET = '(request-target)'
|
||||||
|
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
def initialize(verb, url, options = {})
|
||||||
|
@verb = verb
|
||||||
|
@url = Addressable::URI.parse(url).normalize
|
||||||
|
@options = options
|
||||||
|
@headers = {}
|
||||||
|
|
||||||
|
set_common_headers!
|
||||||
|
end
|
||||||
|
|
||||||
|
def on_behalf_of(account)
|
||||||
|
raise ArgumentError unless account.local?
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_headers(new_headers)
|
||||||
|
@headers.merge!(new_headers)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
http_client.headers(headers).public_send(@verb, @url.to_s, @options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def headers
|
||||||
|
(@account ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_common_headers!
|
||||||
|
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
|
||||||
|
@headers['User-Agent'] = user_agent
|
||||||
|
@headers['Host'] = @url.host
|
||||||
|
@headers['Date'] = Time.now.utc.httpdate
|
||||||
|
end
|
||||||
|
|
||||||
|
def signature
|
||||||
|
key_id = @account.to_webfinger_s
|
||||||
|
algorithm = 'rsa-sha256'
|
||||||
|
signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
|
||||||
|
|
||||||
|
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers}\",signature=\"#{signature}\""
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_string
|
||||||
|
@headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def signed_headers
|
||||||
|
@headers.keys.join(' ').downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_agent
|
||||||
|
@user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def timeout
|
||||||
|
{ write: 10, connect: 10, read: 10 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def http_client
|
||||||
|
HTTP.timeout(:per_operation, timeout).follow
|
||||||
|
end
|
||||||
|
end
|
|
@ -70,7 +70,7 @@ class TagManager
|
||||||
|
|
||||||
uri = Addressable::URI.new
|
uri = Addressable::URI.new
|
||||||
uri.host = domain.gsub(/[\/]/, '')
|
uri.host = domain.gsub(/[\/]/, '')
|
||||||
uri.normalize.host
|
uri.normalized_host
|
||||||
end
|
end
|
||||||
|
|
||||||
def same_acct?(canonical, needle)
|
def same_acct?(canonical, needle)
|
||||||
|
|
|
@ -23,6 +23,7 @@ class UserSettingsDecorator
|
||||||
user.settings['delete_modal'] = delete_modal_preference
|
user.settings['delete_modal'] = delete_modal_preference
|
||||||
user.settings['auto_play_gif'] = auto_play_gif_preference
|
user.settings['auto_play_gif'] = auto_play_gif_preference
|
||||||
user.settings['system_font_ui'] = system_font_ui_preference
|
user.settings['system_font_ui'] = system_font_ui_preference
|
||||||
|
user.settings['noindex'] = noindex_preference
|
||||||
end
|
end
|
||||||
|
|
||||||
def merged_notification_emails
|
def merged_notification_emails
|
||||||
|
@ -57,6 +58,10 @@ class UserSettingsDecorator
|
||||||
boolean_cast_setting 'setting_auto_play_gif'
|
boolean_cast_setting 'setting_auto_play_gif'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def noindex_preference
|
||||||
|
boolean_cast_setting 'setting_noindex'
|
||||||
|
end
|
||||||
|
|
||||||
def boolean_cast_setting(key)
|
def boolean_cast_setting(key)
|
||||||
settings[key] == '1'
|
settings[key] == '1'
|
||||||
end
|
end
|
||||||
|
|
|
@ -47,6 +47,7 @@ class Account < ApplicationRecord
|
||||||
include AccountInteractions
|
include AccountInteractions
|
||||||
include Attachmentable
|
include Attachmentable
|
||||||
include Remotable
|
include Remotable
|
||||||
|
include EmojiHelper
|
||||||
|
|
||||||
# Local users
|
# Local users
|
||||||
has_one :user, inverse_of: :account
|
has_one :user, inverse_of: :account
|
||||||
|
@ -129,7 +130,7 @@ class Account < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscription(webhook_url)
|
def subscription(webhook_url)
|
||||||
OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 86_400 * 30, webhook: webhook_url, hub: hub_url)
|
OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
def save_with_optional_media!
|
def save_with_optional_media!
|
||||||
|
@ -240,9 +241,18 @@ class Account < ApplicationRecord
|
||||||
|
|
||||||
before_create :generate_keys
|
before_create :generate_keys
|
||||||
before_validation :normalize_domain
|
before_validation :normalize_domain
|
||||||
|
before_validation :prepare_contents, if: :local?
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def prepare_contents
|
||||||
|
display_name&.strip!
|
||||||
|
note&.strip!
|
||||||
|
|
||||||
|
self.display_name = emojify(display_name)
|
||||||
|
self.note = emojify(note)
|
||||||
|
end
|
||||||
|
|
||||||
def generate_keys
|
def generate_keys
|
||||||
return unless local?
|
return unless local?
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Remotable
|
module Remotable
|
||||||
include HttpHelper
|
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
|
@ -20,7 +19,7 @@ module Remotable
|
||||||
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url
|
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url
|
||||||
|
|
||||||
begin
|
begin
|
||||||
response = http_client.get(url)
|
response = Request.new(:get, url).perform
|
||||||
|
|
||||||
return if response.code != 200
|
return if response.code != 200
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# severity :integer default("silence")
|
# severity :integer default("silence")
|
||||||
# reject_media :boolean
|
# reject_media :boolean default(FALSE), not null
|
||||||
#
|
#
|
||||||
|
|
||||||
class DomainBlock < ApplicationRecord
|
class DomainBlock < ApplicationRecord
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# account_id :integer not null
|
# account_id :integer not null
|
||||||
# type :integer not null
|
# type :integer not null
|
||||||
# approved :boolean
|
# approved :boolean default(FALSE), not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# data_file_name :string
|
# data_file_name :string
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue