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"
|
||||
}
|
||||
]
|
||||
],
|
||||
"preval"
|
||||
],
|
||||
"env": {
|
||||
"development": {
|
||||
|
|
|
@ -31,6 +31,17 @@ PAPERCLIP_SECRET=
|
|||
SECRET_KEY_BASE=
|
||||
OTP_SECRET=
|
||||
|
||||
# VAPID keys (used for push notifications
|
||||
# You can generate the keys using the following command (first is the private key, second is the public one)
|
||||
# You should only generate this once per instance. If you later decide to change it, all push subscription will
|
||||
# be invalidated, requiring the users to access the website again to resubscribe.
|
||||
#
|
||||
# Generate with `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
|
||||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
||||
# SINGLE_USER_MODE=true
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -21,6 +21,7 @@ public/system
|
|||
public/assets
|
||||
public/packs
|
||||
public/packs-test
|
||||
public/sw.js
|
||||
.env
|
||||
.env.production
|
||||
node_modules/
|
||||
|
|
|
@ -6,3 +6,4 @@ plugins:
|
|||
- last 2 versions
|
||||
- IE >= 11
|
||||
- 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 'doorkeeper', '~> 4.2'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'gemoji', '~> 3.0'
|
||||
gem 'goldfinger', '~> 1.2'
|
||||
gem 'hiredis', '~> 0.6'
|
||||
gem 'redis-namespace', '~> 1.5'
|
||||
|
@ -35,6 +36,7 @@ gem 'htmlentities', '~> 4.3'
|
|||
gem 'http', '~> 2.2'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 0.99'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'kaminari', '~> 1.0'
|
||||
gem 'link_header', '~> 0.0'
|
||||
gem 'mime-types', '~> 3.1'
|
||||
|
@ -64,6 +66,7 @@ gem 'statsd-instrument', '~> 2.1'
|
|||
gem 'twitter-text', '~> 1.14'
|
||||
gem 'tzinfo-data', '~> 1.2017'
|
||||
gem 'webpacker', '~> 2.0'
|
||||
gem 'webpush'
|
||||
|
||||
group :development, :test do
|
||||
gem 'fabrication', '~> 2.16'
|
||||
|
@ -77,7 +80,7 @@ group :test do
|
|||
gem 'capybara', '~> 2.14'
|
||||
gem 'climate_control', '~> 0.2'
|
||||
gem 'faker', '~> 1.7'
|
||||
gem 'microformats2', '~> 3.0'
|
||||
gem 'microformats', '~> 4.0'
|
||||
gem 'rails-controller-testing', '~> 1.0'
|
||||
gem 'rspec-sidekiq', '~> 3.0'
|
||||
gem 'simplecov', '~> 0.14', require: false
|
||||
|
|
14
Gemfile.lock
14
Gemfile.lock
|
@ -163,6 +163,7 @@ GEM
|
|||
fuubar (2.2.0)
|
||||
rspec-core (~> 3.0)
|
||||
ruby-progressbar (~> 1.4)
|
||||
gemoji (3.0.0)
|
||||
globalid (0.4.0)
|
||||
activesupport (>= 4.2.0)
|
||||
goldfinger (1.2.0)
|
||||
|
@ -181,6 +182,7 @@ GEM
|
|||
hashdiff (0.3.4)
|
||||
highline (1.7.8)
|
||||
hiredis (0.6.1)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (2.2.2)
|
||||
addressable (~> 2.3)
|
||||
|
@ -206,9 +208,11 @@ GEM
|
|||
parser (>= 2.2.3.0)
|
||||
rainbow (~> 2.2)
|
||||
terminal-table (>= 1.5.1)
|
||||
idn-ruby (0.1.0)
|
||||
jmespath (1.3.1)
|
||||
json (2.1.0)
|
||||
jsonapi-renderer (0.1.2)
|
||||
jwt (1.5.6)
|
||||
kaminari (1.0.1)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.0.1)
|
||||
|
@ -239,7 +243,7 @@ GEM
|
|||
mail (2.6.6)
|
||||
mime-types (>= 1.16, < 4)
|
||||
method_source (0.8.2)
|
||||
microformats2 (3.1.0)
|
||||
microformats (4.0.7)
|
||||
json
|
||||
nokogiri
|
||||
mime-types (3.1)
|
||||
|
@ -475,6 +479,9 @@ GEM
|
|||
activesupport (>= 4.2)
|
||||
multi_json (~> 1.2)
|
||||
railties (>= 4.2)
|
||||
webpush (0.3.2)
|
||||
hkdf (~> 0.2)
|
||||
jwt
|
||||
websocket-driver (0.6.5)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.2)
|
||||
|
@ -513,6 +520,7 @@ DEPENDENCIES
|
|||
faker (~> 1.7)
|
||||
fast_blank (~> 1.0)
|
||||
fuubar (~> 2.2)
|
||||
gemoji (~> 3.0)
|
||||
goldfinger (~> 1.2)
|
||||
hamlit-rails (~> 0.2)
|
||||
hiredis (~> 0.6)
|
||||
|
@ -521,12 +529,13 @@ DEPENDENCIES
|
|||
http_accept_language (~> 2.1)
|
||||
httplog (~> 0.99)
|
||||
i18n-tasks (~> 0.9)
|
||||
idn-ruby
|
||||
kaminari (~> 1.0)
|
||||
letter_opener (~> 1.4)
|
||||
letter_opener_web (~> 1.3)
|
||||
link_header (~> 0.0)
|
||||
lograge (~> 0.5)
|
||||
microformats2 (~> 3.0)
|
||||
microformats (~> 4.0)
|
||||
mime-types (~> 3.1)
|
||||
nokogiri (~> 1.7)
|
||||
oj (~> 3.0)
|
||||
|
@ -573,6 +582,7 @@ DEPENDENCIES
|
|||
uglifier (~> 3.2)
|
||||
webmock (~> 3.0)
|
||||
webpacker (~> 2.0)
|
||||
webpush
|
||||
|
||||
RUBY VERSION
|
||||
ruby 2.4.1p111
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class AccountsController < ApplicationController
|
||||
include AccountControllerConcern
|
||||
include SignatureVerification
|
||||
|
||||
def show
|
||||
respond_to do |format|
|
||||
|
@ -15,7 +16,9 @@ class AccountsController < ApplicationController
|
|||
render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
|
||||
end
|
||||
|
||||
format.activitystreams2
|
||||
format.json do
|
||||
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
|
||||
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
|
||||
|
||||
class Api::PushController < Api::BaseController
|
||||
include SignatureVerification
|
||||
|
||||
def update
|
||||
response, status = process_push_request
|
||||
render plain: response, status: status
|
||||
|
@ -11,7 +13,7 @@ class Api::PushController < Api::BaseController
|
|||
def process_push_request
|
||||
case hub_mode
|
||||
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'
|
||||
Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback)
|
||||
else
|
||||
|
@ -57,6 +59,10 @@ class Api::PushController < Api::BaseController
|
|||
TagManager.instance.web_domain?(hub_topic_domain)
|
||||
end
|
||||
|
||||
def verified_domain
|
||||
return signed_request_account.domain if signed_request_account
|
||||
end
|
||||
|
||||
def hub_topic_domain
|
||||
hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '')
|
||||
end
|
||||
|
|
|
@ -42,7 +42,7 @@ class Api::SubscriptionsController < Api::BaseController
|
|||
end
|
||||
|
||||
def lease_seconds_or_default
|
||||
(params['hub.lease_seconds'] || 86_400).to_i.seconds
|
||||
(params['hub.lease_seconds'] || 1.day).to_i.seconds
|
||||
end
|
||||
|
||||
def set_account
|
||||
|
|
|
@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
|
|||
|
||||
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
|
||||
|
||||
private
|
||||
|
|
|
@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||
authorize status_for_destroy, :unreblog?
|
||||
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
|
||||
|
||||
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
|
||||
@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
|
||||
|
|
|
@ -5,5 +5,25 @@ class FollowingAccountsController < ApplicationController
|
|||
|
||||
def index
|
||||
@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
|
||||
|
|
|
@ -22,6 +22,7 @@ class HomeController < ApplicationController
|
|||
def initial_state_params
|
||||
{
|
||||
settings: Web::Setting.find_by(user: current_user)&.data || {},
|
||||
push_subscription: current_account.user.web_push_subscription(current_session),
|
||||
current_account: current_account,
|
||||
token: current_session.token,
|
||||
admin: Account.find_local(Setting.site_contact_username),
|
||||
|
|
|
@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController
|
|||
:setting_delete_modal,
|
||||
:setting_auto_play_gif,
|
||||
:setting_system_font_ui,
|
||||
:setting_noindex,
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest),
|
||||
interactions: %i(must_be_follower must_be_following)
|
||||
)
|
||||
|
|
|
@ -11,10 +11,22 @@ class StatusesController < ApplicationController
|
|||
before_action :check_account_suspension
|
||||
|
||||
def show
|
||||
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
|
||||
@descendants = cache_collection(@status.descendants(current_account), Status)
|
||||
respond_to do |format|
|
||||
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
|
||||
|
||||
private
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class StreamEntriesController < ApplicationController
|
||||
include Authorization
|
||||
include SignatureVerification
|
||||
|
||||
layout 'public'
|
||||
|
||||
|
|
|
@ -5,7 +5,27 @@ class TagsController < ApplicationController
|
|||
|
||||
def show
|
||||
@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)
|
||||
|
||||
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
|
||||
|
|
|
@ -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 * as emojione from 'emojione';
|
||||
|
||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||
|
@ -74,10 +72,12 @@ export function mentionCompose(account, router) {
|
|||
|
||||
export function submitCompose() {
|
||||
return function (dispatch, getState) {
|
||||
let status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], ''));
|
||||
const status = getState().getIn(['compose', 'text'], '');
|
||||
|
||||
if (!status || !status.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(submitComposeRequest());
|
||||
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
|
||||
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 = {
|
||||
src: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
time: PropTypes.number,
|
||||
controls: PropTypes.bool.isRequired,
|
||||
muted: PropTypes.bool.isRequired,
|
||||
|
@ -30,7 +32,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
|
|||
|
||||
render () {
|
||||
return (
|
||||
<div className='extended-video-player'>
|
||||
<div className='extended-video-player' style={{ width: this.props.width, height: this.props.height }}>
|
||||
<video
|
||||
ref={this.setRef}
|
||||
src={this.props.src}
|
||||
|
|
|
@ -6,11 +6,18 @@ export default class LoadMore extends React.PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
visible: true,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { visible } = this.props;
|
||||
|
||||
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' />
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -101,13 +101,9 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
render () {
|
||||
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;
|
||||
|
||||
if (!isLoading && statusIds.size > 0 && hasMore) {
|
||||
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
||||
}
|
||||
|
||||
if (isLoading || statusIds.size > 0 || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className='scrollable' ref={this.setRef}>
|
||||
|
|
|
@ -1,49 +1,28 @@
|
|||
import emojione from 'emojione';
|
||||
import { unicodeToFilename } from './emojione_light';
|
||||
import Trie from 'substring-trie';
|
||||
|
||||
const mappedUnicode = emojione.mapUnicodeToShort();
|
||||
const trie = new Trie(Object.keys(emojione.jsEscapeMap));
|
||||
const trie = new Trie(Object.keys(unicodeToFilename));
|
||||
|
||||
function emojify(str) {
|
||||
// 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.
|
||||
// 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 insideTag = false;
|
||||
let insideShortname = false;
|
||||
let shortnameStartIndex = -1;
|
||||
let match;
|
||||
while (++i < str.length) {
|
||||
const char = str.charAt(i);
|
||||
if (insideShortname && 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 === '>') {
|
||||
if (insideTag && char === '>') {
|
||||
insideTag = false;
|
||||
} else if (char === '<') {
|
||||
insideTag = true;
|
||||
insideShortname = false;
|
||||
} else if (!insideTag && char === ':') {
|
||||
insideShortname = true;
|
||||
shortnameStartIndex = i;
|
||||
} else if (!insideTag && (match = trie.search(str.substring(i)))) {
|
||||
const unicodeStr = match;
|
||||
if (unicodeStr in emojione.jsEscapeMap) {
|
||||
const unicode = emojione.jsEscapeMap[unicodeStr];
|
||||
const short = mappedUnicode[unicode];
|
||||
const filename = emojione.emojioneList[short].fname;
|
||||
const alt = emojione.convert(unicode.toUpperCase());
|
||||
const replacement = `<img draggable="false" class="emojione" alt="${alt}" title="${short}" src="/emoji/${filename}.svg" />`;
|
||||
if (unicodeStr in unicodeToFilename) {
|
||||
const filename = unicodeToFilename[unicodeStr];
|
||||
const alt = unicodeStr;
|
||||
const replacement = `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
|
||||
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
|
||||
}
|
||||
|
|
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 'requestidlecallback';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
|
||||
objectFitImages();
|
||||
|
|
|
@ -140,7 +140,8 @@ export default class ComposeForm extends ImmutablePureComponent {
|
|||
|
||||
handleEmojiPick = (data) => {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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}>
|
||||
<DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}>
|
||||
<img
|
||||
draggable='false'
|
||||
className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
|
||||
alt='🙂' src='/emoji/1f602.svg'
|
||||
alt='🙂'
|
||||
src='/emoji/1f602.svg'
|
||||
/>
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent className='dropdown__left'>
|
||||
{
|
||||
this.state.active && !this.state.loading &&
|
||||
|
|
|
@ -2,11 +2,11 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
|
||||
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 ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
|
@ -16,8 +16,6 @@ const messages = defineMessages({
|
|||
|
||||
const mapStateToProps = state => ({
|
||||
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
|
||||
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
|
||||
me: state.getIn(['meta', 'me']),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
|
@ -27,34 +25,64 @@ export default class Favourites extends ImmutablePureComponent {
|
|||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
loaded: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
me: PropTypes.number.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
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 = () => {
|
||||
this.props.dispatch(expandFavouritedStatuses());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { loaded, intl } = this.props;
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
const { intl, statusIds, columnId, multiColumn } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
<Column icon='star' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
<StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} />
|
||||
<Column ref={this.setRef}>
|
||||
<ColumnHeader
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
pushSettings: ImmutablePropTypes.map.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
onPushChange = (key, checked) => {
|
||||
this.props.onChange(['push', ...key], checked);
|
||||
}
|
||||
|
||||
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 showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
||||
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 (
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<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={['sounds', 'follow']} onChange={onChange} label={soundStr} />
|
||||
</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>
|
||||
|
||||
<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={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
|
||||
</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>
|
||||
|
||||
<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={['sounds', 'mention']} onChange={onChange} label={soundStr} />
|
||||
</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>
|
||||
|
||||
<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={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent {
|
|||
settings: ImmutablePropTypes.map.isRequired,
|
||||
settingKey: PropTypes.array.isRequired,
|
||||
label: PropTypes.node.isRequired,
|
||||
meta: PropTypes.node,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
|
@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent {
|
|||
}
|
||||
|
||||
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('-');
|
||||
|
||||
return (
|
||||
<div className='setting-toggle'>
|
||||
<Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
|
||||
<label htmlFor={id} className='setting-toggle__label'>{label}</label>
|
||||
{meta && <span className='setting-meta__label'>{meta}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
|||
import ColumnSettings from '../components/column_settings';
|
||||
import { changeSetting, saveSettings } from '../../../actions/settings';
|
||||
import { clearNotifications } from '../../../actions/notifications';
|
||||
import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -12,16 +13,22 @@ const messages = defineMessages({
|
|||
|
||||
const mapStateToProps = state => ({
|
||||
settings: state.getIn(['settings', 'notifications']),
|
||||
pushSettings: state.get('push_notifications'),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
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 () {
|
||||
dispatch(saveSettings());
|
||||
dispatch(savePushNotificationSettings());
|
||||
},
|
||||
|
||||
onClear () {
|
||||
|
|
|
@ -9,7 +9,7 @@ import { links, getIndex, getLink } from './tabs_bar';
|
|||
import BundleContainer from '../containers/bundle_container';
|
||||
import ColumnLoading from './column_loading';
|
||||
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 = {
|
||||
'COMPOSE': Compose,
|
||||
|
@ -18,6 +18,7 @@ const componentMap = {
|
|||
'PUBLIC': PublicTimeline,
|
||||
'COMMUNITY': CommunityTimeline,
|
||||
'HASHTAG': HashtagTimeline,
|
||||
'FAVOURITES': FavouritedStatuses,
|
||||
};
|
||||
|
||||
export default class ColumnsArea extends ImmutablePureComponent {
|
||||
|
@ -32,12 +33,33 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
|||
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) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.context.router.history.push(getLink(index));
|
||||
});
|
||||
});
|
||||
this.pendingIndex = index;
|
||||
}
|
||||
|
||||
handleAnimationEnd = () => {
|
||||
if (typeof this.pendingIndex === 'number') {
|
||||
this.context.router.history.push(getLink(this.pendingIndex));
|
||||
this.pendingIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
renderView = (link, index) => {
|
||||
|
@ -66,12 +88,14 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
|||
|
||||
render () {
|
||||
const { columns, children, singleColumn } = this.props;
|
||||
const { shouldAnimate } = this.state;
|
||||
|
||||
const columnIndex = getIndex(this.context.router.history.location.pathname);
|
||||
this.pendingIndex = null;
|
||||
|
||||
if (singleColumn) {
|
||||
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)}
|
||||
</ReactSwipeableViews>
|
||||
) : <div className='columns-area'>{children}</div>;
|
||||
|
|
|
@ -65,8 +65,6 @@ export default class MediaModal extends ImmutablePureComponent {
|
|||
const { media, intl, onClose } = this.props;
|
||||
|
||||
const index = this.getIndex();
|
||||
const attachment = media.get(index);
|
||||
const url = attachment.get('url');
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
if (attachment.get('type') === 'image') {
|
||||
content = media.map((image) => {
|
||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = image.getIn(['meta', 'original', 'height']) || null;
|
||||
content = media.map((image) => {
|
||||
const width = image.getIn(['meta', 'original', 'width']) || 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')} />;
|
||||
}).toArray();
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
content = <ExtendedVideoPlayer src={url} muted controls={false} />;
|
||||
}
|
||||
} else if (image.get('type') === 'gifv') {
|
||||
return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}).toArray();
|
||||
|
||||
return (
|
||||
<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) };
|
||||
}
|
||||
|
||||
renderModal = (SpecificComponent) => {
|
||||
const { props, onClose } = this.props;
|
||||
|
||||
return <SpecificComponent {...props} onClose={onClose} />;
|
||||
}
|
||||
|
||||
renderLoading = () => {
|
||||
return <ModalLoading />;
|
||||
}
|
||||
|
@ -97,7 +91,9 @@ export default class ModalRoot extends React.PureComponent {
|
|||
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||
<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})` }}>
|
||||
<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>
|
||||
))}
|
||||
|
|
|
@ -20,11 +20,12 @@ function loadPolyfills() {
|
|||
);
|
||||
|
||||
// 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.
|
||||
const needsExtraPolyfills = !(
|
||||
window.IntersectionObserver &&
|
||||
window.requestIdleCallback
|
||||
window.requestIdleCallback &&
|
||||
'object-fit' in (new Image()).style
|
||||
);
|
||||
|
||||
return Promise.all([
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "المُفَضَّلة :",
|
||||
"notifications.column_settings.follow": "متابعُون جُدُد :",
|
||||
"notifications.column_settings.mention": "الإشارات :",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "الترقيّات:",
|
||||
"notifications.column_settings.show": "إعرِضها في عمود",
|
||||
"notifications.column_settings.sound": "أصدر صوتا",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "إبلاغ",
|
||||
"search.placeholder": "ابحث",
|
||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "تعذرت ترقية هذا المنشور",
|
||||
"status.delete": "إحذف",
|
||||
"status.favourite": "أضف إلى المفضلة",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Предпочитани:",
|
||||
"notifications.column_settings.follow": "Нови последователи:",
|
||||
"notifications.column_settings.mention": "Споменавания:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "Споделяния:",
|
||||
"notifications.column_settings.show": "Покажи в колона",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Reporting",
|
||||
"search.placeholder": "Търсене",
|
||||
"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.delete": "Изтриване",
|
||||
"status.favourite": "Предпочитани",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favorits:",
|
||||
"notifications.column_settings.follow": "Nous seguidors:",
|
||||
"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.show": "Mostrar en la columna",
|
||||
"notifications.column_settings.sound": "Reproduïr so",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Informes",
|
||||
"search.placeholder": "Cercar",
|
||||
"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.delete": "Esborrar",
|
||||
"status.favourite": "Favorit",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favorisierungen:",
|
||||
"notifications.column_settings.follow": "Neue Folgende:",
|
||||
"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.show": "In der Spalte anzeigen",
|
||||
"notifications.column_settings.sound": "Ton abspielen",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Melden",
|
||||
"search.placeholder": "Suche",
|
||||
"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.delete": "Löschen",
|
||||
"status.favourite": "Favorisieren",
|
||||
|
|
|
@ -889,6 +889,14 @@
|
|||
"defaultMessage": "Play 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:",
|
||||
"id": "notifications.column_settings.follow"
|
||||
|
@ -964,6 +972,15 @@
|
|||
],
|
||||
"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": [
|
||||
{
|
||||
|
|
|
@ -114,6 +114,8 @@
|
|||
"notifications.column_settings.favourite": "Favourites:",
|
||||
"notifications.column_settings.follow": "New followers:",
|
||||
"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.show": "Show in column",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
|
@ -170,6 +172,7 @@
|
|||
"settings.media_fullwidth": "Full-width media previews",
|
||||
"settings.preferences": "User preferences",
|
||||
"settings.wide_view": "Wide view (Desktop mode only)",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "This post cannot be boosted",
|
||||
"status.collapse": "Collapse",
|
||||
"status.delete": "Delete",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favoroj:",
|
||||
"notifications.column_settings.follow": "Novaj sekvantoj:",
|
||||
"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.show": "Montri en kolono",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Reporting",
|
||||
"search.placeholder": "Serĉi",
|
||||
"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.delete": "Forigi",
|
||||
"status.favourite": "Favori",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favoritos:",
|
||||
"notifications.column_settings.follow": "Nuevos seguidores:",
|
||||
"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.show": "Mostrar en columna",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Reporting",
|
||||
"search.placeholder": "Buscar",
|
||||
"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.delete": "Borrar",
|
||||
"status.favourite": "Favorito",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "پسندیدهها:",
|
||||
"notifications.column_settings.follow": "پیگیران تازه:",
|
||||
"notifications.column_settings.mention": "نامبردنها:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "بازبوقها:",
|
||||
"notifications.column_settings.show": "نمایش در ستون",
|
||||
"notifications.column_settings.sound": "پخش صدا",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "گزارشدادن",
|
||||
"search.placeholder": "جستجو",
|
||||
"search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "این نوشته را نمیشود بازبوقید",
|
||||
"status.delete": "پاککردن",
|
||||
"status.favourite": "پسندیدن",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Tykkäyksiä:",
|
||||
"notifications.column_settings.follow": "Uusia seuraajia:",
|
||||
"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.show": "Näytä sarakkeessa",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Reporting",
|
||||
"search.placeholder": "Hae",
|
||||
"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.delete": "Poista",
|
||||
"status.favourite": "Tykkää",
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
"column.favourites": "Favoris",
|
||||
"column.follow_requests": "Demandes de suivi",
|
||||
"column.home": "Accueil",
|
||||
"column.mutes": "Comptes silencés",
|
||||
"column.mutes": "Comptes masqués",
|
||||
"column.notifications": "Notifications",
|
||||
"column.public": "Fil public global",
|
||||
"column_back_button.label": "Retour",
|
||||
|
@ -52,9 +52,9 @@
|
|||
"confirmations.delete.confirm": "Supprimer",
|
||||
"confirmations.delete.message": "Confirmez vous la suppression de ce pouet ?",
|
||||
"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.mute.confirm": "Silencer",
|
||||
"confirmations.mute.message": "Confirmez vous la silenciation {name} ?",
|
||||
"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": "Masquer",
|
||||
"confirmations.mute.message": "Confirmez vous le masquage de {name} ?",
|
||||
"emoji_button.activity": "Activités",
|
||||
"emoji_button.flags": "Drapeaux",
|
||||
"emoji_button.food": "Boire et manger",
|
||||
|
@ -96,7 +96,7 @@
|
|||
"navigation_bar.follow_requests": "Demandes de suivi",
|
||||
"navigation_bar.info": "Plus d’informations",
|
||||
"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.public_timeline": "Fil public global",
|
||||
"notification.favourite": "{name} a ajouté à ses favoris :",
|
||||
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favoris :",
|
||||
"notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s :",
|
||||
"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.show": "Afficher dans la colonne",
|
||||
"notifications.column_settings.sound": "Émettre un son",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Signalement",
|
||||
"search.placeholder": "Rechercher",
|
||||
"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.delete": "Effacer",
|
||||
"status.favourite": "Ajouter aux favoris",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "מחובבים:",
|
||||
"notifications.column_settings.follow": "עוקבים חדשים:",
|
||||
"notifications.column_settings.mention": "פניות:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "הדהודים:",
|
||||
"notifications.column_settings.show": "הצגה בטור",
|
||||
"notifications.column_settings.sound": "שמע מופעל",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "דיווח",
|
||||
"search.placeholder": "חיפוש",
|
||||
"search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "לא ניתן להדהד הודעה זו",
|
||||
"status.delete": "מחיקה",
|
||||
"status.favourite": "חיבוב",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favoriti:",
|
||||
"notifications.column_settings.follow": "Novi sljedbenici:",
|
||||
"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.show": "Prikaži u stupcu",
|
||||
"notifications.column_settings.sound": "Sviraj zvuk",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Prijavljivanje",
|
||||
"search.placeholder": "Traži",
|
||||
"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.delete": "Obriši",
|
||||
"status.favourite": "Označi omiljenim",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favourites:",
|
||||
"notifications.column_settings.follow": "New followers:",
|
||||
"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.show": "Show in column",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Reporting",
|
||||
"search.placeholder": "Keresés",
|
||||
"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.delete": "Törlés",
|
||||
"status.favourite": "Kedvenc",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favorit:",
|
||||
"notifications.column_settings.follow": "Pengikut baru:",
|
||||
"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.show": "Tampilkan dalam kolom",
|
||||
"notifications.column_settings.sound": "Mainkan suara",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Melaporkan",
|
||||
"search.placeholder": "Pencarian",
|
||||
"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.delete": "Hapus",
|
||||
"status.favourite": "Difavoritkan",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favorati:",
|
||||
"notifications.column_settings.follow": "Nova sequanti:",
|
||||
"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.show": "Montrar en kolumno",
|
||||
"notifications.column_settings.sound": "Plear sono",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Denuncante",
|
||||
"search.placeholder": "Serchez",
|
||||
"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.delete": "Efacar",
|
||||
"status.favourite": "Favorizar",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Apprezzati:",
|
||||
"notifications.column_settings.follow": "Nuovi seguaci:",
|
||||
"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.show": "Mostra in colonna",
|
||||
"notifications.column_settings.sound": "Riproduci suono",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Invio la segnalazione",
|
||||
"search.placeholder": "Cerca",
|
||||
"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.delete": "Elimina",
|
||||
"status.favourite": "Apprezzato",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "お気に入り",
|
||||
"notifications.column_settings.follow": "新しいフォロワー",
|
||||
"notifications.column_settings.mention": "返信",
|
||||
"notifications.column_settings.push": "プッシュ通知",
|
||||
"notifications.column_settings.push_meta": "このデバイス",
|
||||
"notifications.column_settings.reblog": "ブースト",
|
||||
"notifications.column_settings.show": "カラムに表示",
|
||||
"notifications.column_settings.sound": "通知音を再生",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "問題のユーザー",
|
||||
"search.placeholder": "検索",
|
||||
"search_results.total": "{count, number}件の結果",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "この投稿はブーストできません",
|
||||
"status.delete": "削除",
|
||||
"status.favourite": "お気に入り",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "즐겨찾기",
|
||||
"notifications.column_settings.follow": "새 팔로워",
|
||||
"notifications.column_settings.mention": "답글",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "부스트",
|
||||
"notifications.column_settings.show": "컬럼에 표시",
|
||||
"notifications.column_settings.sound": "효과음 재생",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "문제가 된 사용자",
|
||||
"search.placeholder": "검색",
|
||||
"search_results.total": "{count, number}건의 결과",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
|
||||
"status.delete": "삭제",
|
||||
"status.favourite": "즐겨찾기",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favorieten:",
|
||||
"notifications.column_settings.follow": "Nieuwe volgers:",
|
||||
"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.show": "In kolom tonen",
|
||||
"notifications.column_settings.sound": "Geluid afspelen",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Rapporteren van",
|
||||
"search.placeholder": "Zoeken",
|
||||
"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.delete": "Verwijderen",
|
||||
"status.favourite": "Favoriet",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Likt:",
|
||||
"notifications.column_settings.follow": "Nye følgere:",
|
||||
"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.show": "Vis i kolonne",
|
||||
"notifications.column_settings.sound": "Spill lyd",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Rapporterer",
|
||||
"search.placeholder": "Søk",
|
||||
"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.delete": "Slett",
|
||||
"status.favourite": "Lik",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favorits :",
|
||||
"notifications.column_settings.follow": "Nòus seguidors :",
|
||||
"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.show": "Mostrar dins la colomna",
|
||||
"notifications.column_settings.sound": "Emetre un son",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Senhalar {target}",
|
||||
"search.placeholder": "Recercar",
|
||||
"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.delete": "Escafar",
|
||||
"status.favourite": "Apondre als favorits",
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
"account.block_domain": "Blokuj wszystko z {domain}",
|
||||
"account.disclaimer": "Ten użytkownik pochodzi z innej instancji. Ta liczba może być większa.",
|
||||
"account.edit_profile": "Edytuj profil",
|
||||
"account.follow": "Obserwuj",
|
||||
"account.followers": "Obserwujący",
|
||||
"account.follows": "Obserwacje",
|
||||
"account.follows_you": "Obserwuje cię",
|
||||
"account.follow": "Śledź",
|
||||
"account.followers": "Śledzący",
|
||||
"account.follows": "Śledzeni",
|
||||
"account.follows_you": "Śledzi Cię",
|
||||
"account.media": "Media",
|
||||
"account.mention": "Wspomnij o @{name}",
|
||||
"account.mute": "Wycisz @{name}",
|
||||
|
@ -15,7 +15,7 @@
|
|||
"account.requested": "Oczekująca prośba",
|
||||
"account.unblock": "Odblokuj @{name}",
|
||||
"account.unblock_domain": "Odblokuj domenę {domain}",
|
||||
"account.unfollow": "Przestań obserwować",
|
||||
"account.unfollow": "Przestań śledzić",
|
||||
"account.unmute": "Cofnij wyciszenie @{name}",
|
||||
"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.",
|
||||
|
@ -27,7 +27,7 @@
|
|||
"column.blocks": "Zablokowani użytkownicy",
|
||||
"column.community": "Lokalna oś czasu",
|
||||
"column.favourites": "Ulubione",
|
||||
"column.follow_requests": "Prośby o obserwację",
|
||||
"column.follow_requests": "Prośby o śledzenie",
|
||||
"column.home": "Strona główna",
|
||||
"column.mutes": "Wyciszeni użytkownicy",
|
||||
"column.notifications": "Powiadomienia",
|
||||
|
@ -37,9 +37,9 @@
|
|||
"column_header.unpin": "Cofnij przypięcie",
|
||||
"column_subheading.navigation": "Nawigacja",
|
||||
"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.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.publish": "Wyślij",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
|
@ -67,7 +67,7 @@
|
|||
"emoji_button.travel": "Podróże i miejsca",
|
||||
"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.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.public_timeline": "publiczna oś czasu",
|
||||
"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.edit_profile": "Edytuj profil",
|
||||
"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.logout": "Wyloguj",
|
||||
"navigation_bar.mutes": "Wyciszeni użytkownicy",
|
||||
"navigation_bar.preferences": "Preferencje",
|
||||
"navigation_bar.public_timeline": "Oś czasu federacji",
|
||||
"notification.favourite": "{name} dodał twój status do ulubionych",
|
||||
"notification.follow": "{name} zaczął cię obserwować",
|
||||
"notification.favourite": "{name} dodał Twój status do ulubionych",
|
||||
"notification.follow": "{name} zaczął Cię śledzić",
|
||||
"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_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
|
||||
"notifications.column_settings.alert": "Powiadomienia na pulpicie",
|
||||
"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.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "Podbili:",
|
||||
"notifications.column_settings.show": "Pokaż w kolumnie",
|
||||
"notifications.column_settings.sound": "Odtwarzaj dźwięk",
|
||||
"onboarding.done": "Gotowe",
|
||||
"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.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.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_six.admin": "Administratorem tej instancji jest {admin}.",
|
||||
"onboarding.page_six.almost_done": "Prawie gotowe...",
|
||||
|
@ -135,8 +137,8 @@
|
|||
"privacy.change": "Dostosuj widoczność postów",
|
||||
"privacy.direct.long": "Widoczne tylko dla oznaczonych",
|
||||
"privacy.direct.short": "Bezpośrednio",
|
||||
"privacy.private.long": "Widoczne tylko dla obserwujących",
|
||||
"privacy.private.short": "Tylko obserwujący",
|
||||
"privacy.private.long": "Widoczne tylko dla śledzących",
|
||||
"privacy.private.short": "Tylko śledzący",
|
||||
"privacy.public.long": "Widoczne na publicznych osiach czasu",
|
||||
"privacy.public.short": "Publiczne",
|
||||
"privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Zgłaszanie {target}",
|
||||
"search.placeholder": "Szukaj",
|
||||
"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.delete": "Usuń",
|
||||
"status.favourite": "Ulubione",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favoritos:",
|
||||
"notifications.column_settings.follow": "Novos seguidores:",
|
||||
"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.show": "Mostrar nas colunas",
|
||||
"notifications.column_settings.sound": "Reproduzir som",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Denunciar",
|
||||
"search.placeholder": "Pesquisar",
|
||||
"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.delete": "Eliminar",
|
||||
"status.favourite": "Adicionar aos favoritos",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favoritos:",
|
||||
"notifications.column_settings.follow": "Novos seguidores:",
|
||||
"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.show": "Mostrar nas colunas",
|
||||
"notifications.column_settings.sound": "Reproduzir som",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Denunciar",
|
||||
"search.placeholder": "Pesquisar",
|
||||
"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.delete": "Eliminar",
|
||||
"status.favourite": "Adicionar aos favoritos",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Нравится:",
|
||||
"notifications.column_settings.follow": "Новые подписчики:",
|
||||
"notifications.column_settings.mention": "Упоминания:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "Продвижения:",
|
||||
"notifications.column_settings.show": "Показывать в колонке",
|
||||
"notifications.column_settings.sound": "Проигрывать звук",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Жалуемся на",
|
||||
"search.placeholder": "Поиск",
|
||||
"search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "Этот статус не может быть продвинут",
|
||||
"status.delete": "Удалить",
|
||||
"status.favourite": "Нравится",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favourites:",
|
||||
"notifications.column_settings.follow": "New followers:",
|
||||
"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.show": "Show in column",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Reporting",
|
||||
"search.placeholder": "Search",
|
||||
"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.delete": "Delete",
|
||||
"status.favourite": "Favourite",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Favoriler:",
|
||||
"notifications.column_settings.follow": "Yeni takipçiler:",
|
||||
"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.show": "Bildirimlerde göster",
|
||||
"notifications.column_settings.sound": "Ses çal",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Raporlama",
|
||||
"search.placeholder": "Ara",
|
||||
"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.delete": "Sil",
|
||||
"status.favourite": "Favorilere ekle",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "Вподобане:",
|
||||
"notifications.column_settings.follow": "Нові підписники:",
|
||||
"notifications.column_settings.mention": "Сповіщення:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "Передмухи:",
|
||||
"notifications.column_settings.show": "Показати в колонці",
|
||||
"notifications.column_settings.sound": "Відтворювати звук",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Скаржимося на",
|
||||
"search.placeholder": "Пошук",
|
||||
"search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "Цей допис не може бути передмухнутий",
|
||||
"status.delete": "Видалити",
|
||||
"status.favourite": "Подобається",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "你的嘟文被赞:",
|
||||
"notifications.column_settings.follow": "关注你:",
|
||||
"notifications.column_settings.mention": "提及你:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "你的嘟文被转嘟:",
|
||||
"notifications.column_settings.show": "在通知栏显示",
|
||||
"notifications.column_settings.sound": "播放音效",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "Reporting",
|
||||
"search.placeholder": "搜索",
|
||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "没法转嘟这条嘟文啦……",
|
||||
"status.delete": "删除",
|
||||
"status.favourite": "赞",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "喜歡你的文章:",
|
||||
"notifications.column_settings.follow": "關注你:",
|
||||
"notifications.column_settings.mention": "提及你:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "轉推你的文章:",
|
||||
"notifications.column_settings.show": "在通知欄顯示",
|
||||
"notifications.column_settings.sound": "播放音效",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "舉報",
|
||||
"search.placeholder": "搜尋",
|
||||
"search_results.total": "{count, number} 項結果",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "這篇文章無法被轉推",
|
||||
"status.delete": "刪除",
|
||||
"status.favourite": "喜歡",
|
||||
|
|
|
@ -109,6 +109,8 @@
|
|||
"notifications.column_settings.favourite": "最愛:",
|
||||
"notifications.column_settings.follow": "新的關注者:",
|
||||
"notifications.column_settings.mention": "提到:",
|
||||
"notifications.column_settings.push": "Push notifications",
|
||||
"notifications.column_settings.push_meta": "This device",
|
||||
"notifications.column_settings.reblog": "轉推:",
|
||||
"notifications.column_settings.show": "顯示在欄位中",
|
||||
"notifications.column_settings.sound": "播放音效",
|
||||
|
@ -147,6 +149,7 @@
|
|||
"report.target": "通報中",
|
||||
"search.placeholder": "搜尋",
|
||||
"search_results.total": "{count, number} 項結果",
|
||||
"standalone.public_title": "A look inside...",
|
||||
"status.cannot_reblog": "此貼文無法轉推",
|
||||
"status.delete": "刪除",
|
||||
"status.favourite": "喜愛",
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
const perf = require('./performance');
|
||||
import ready from './ready';
|
||||
|
||||
function onDomContentLoaded(callback) {
|
||||
if (document.readyState !== 'loading') {
|
||||
callback();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', callback);
|
||||
}
|
||||
}
|
||||
const perf = require('./performance');
|
||||
|
||||
function main() {
|
||||
perf.start('main()');
|
||||
|
@ -24,11 +18,19 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
onDomContentLoaded(() => {
|
||||
ready(() => {
|
||||
const mountNode = document.getElementById('mastodon');
|
||||
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
||||
|
||||
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()');
|
||||
|
||||
// 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 emoji = emojiData.shortname;
|
||||
const emoji = String.fromCodePoint(parseInt(emojiData.unicode, 16));
|
||||
|
||||
return state.withMutations(map => {
|
||||
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 settings from './settings';
|
||||
import local_settings from '../../glitch/reducers/local_settings';
|
||||
import push_notifications from './push_notifications';
|
||||
import status_lists from './status_lists';
|
||||
import cards from './cards';
|
||||
import reports from './reports';
|
||||
|
@ -33,7 +34,11 @@ const reducers = {
|
|||
statuses,
|
||||
relationships,
|
||||
settings,
|
||||
<<<<<<< HEAD
|
||||
local_settings,
|
||||
=======
|
||||
push_notifications,
|
||||
>>>>>>> upstream
|
||||
cards,
|
||||
reports,
|
||||
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 loadPolyfills from '../mastodon/load_polyfills';
|
||||
import { processBio } from '../glitch/util/bio_metadata';
|
||||
import TimelineContainer from '../mastodon/containers/timeline_container';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
require.context('../images/', true);
|
||||
|
||||
|
@ -40,21 +38,10 @@ function loaded() {
|
|||
const datetime = new Date(content.getAttribute('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() {
|
||||
if (['interactive', 'complete'].includes(document.readyState)) {
|
||||
loaded();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', loaded);
|
||||
}
|
||||
ready(loaded);
|
||||
|
||||
delegate(document, '.video-player video', 'click', ({ target }) => {
|
||||
if (target.paused) {
|
||||
|
|
|
@ -1554,6 +1554,9 @@
|
|||
}
|
||||
|
||||
.react-swipeable-view-container > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
@ -2007,6 +2010,7 @@
|
|||
width: 100%;
|
||||
margin: 0;
|
||||
color: $ui-base-color;
|
||||
background: $simple-background-color;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
|
@ -2029,7 +2033,6 @@
|
|||
|
||||
.autosuggest-textarea__textarea {
|
||||
min-height: 100px;
|
||||
background: $simple-background-color;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding-bottom: 0;
|
||||
padding-right: 10px + 22px;
|
||||
|
@ -2620,7 +2623,8 @@ button.icon-button.active i.fa-retweet {
|
|||
line-height: 24px;
|
||||
}
|
||||
|
||||
.setting-toggle__label {
|
||||
.setting-toggle__label,
|
||||
.setting-meta__label {
|
||||
color: $ui-primary-color;
|
||||
display: inline-block;
|
||||
margin-bottom: 14px;
|
||||
|
@ -2628,6 +2632,11 @@ button.icon-button.active i.fa-retweet {
|
|||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.setting-meta__label {
|
||||
color: $ui-primary-color;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.empty-column-indicator,
|
||||
.error-column {
|
||||
color: lighten($ui-base-color, 20%);
|
||||
|
@ -2968,6 +2977,7 @@ button.icon-button.active i.fa-retweet {
|
|||
margin-left: 2px;
|
||||
width: 24px;
|
||||
outline: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
|
@ -3297,6 +3307,7 @@ button.icon-button.active i.fa-retweet {
|
|||
max-height: 80vh;
|
||||
position: relative;
|
||||
|
||||
.extended-video-player,
|
||||
img,
|
||||
canvas,
|
||||
video {
|
||||
|
@ -3306,6 +3317,13 @@ button.icon-button.active i.fa-retweet {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.extended-video-player,
|
||||
video {
|
||||
display: flex;
|
||||
width: 80vw;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
img,
|
||||
canvas {
|
||||
display: block;
|
||||
|
|
|
@ -45,6 +45,10 @@ body.rtl {
|
|||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.setting-meta__label {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.status__avatar {
|
||||
left: auto;
|
||||
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
|
||||
# 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.concat([status.reblog.account_id]) if status.reblog?
|
||||
|
@ -126,12 +126,13 @@ class FeedManager
|
|||
end
|
||||
|
||||
def filter_from_mentions?(status, receiver_id)
|
||||
return true if receiver_id == status.account_id
|
||||
|
||||
check_for_blocks = [status.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?
|
||||
|
||||
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? # or 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? # Filter if 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
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ProviderDiscovery < OEmbed::ProviderDiscovery
|
||||
extend HttpHelper
|
||||
|
||||
class << self
|
||||
def discover_provider(url, options = {})
|
||||
res = http_client.get(url)
|
||||
res = Request.new(:get, url).perform
|
||||
format = options[:format]
|
||||
|
||||
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.host = domain.gsub(/[\/]/, '')
|
||||
uri.normalize.host
|
||||
uri.normalized_host
|
||||
end
|
||||
|
||||
def same_acct?(canonical, needle)
|
||||
|
|
|
@ -23,6 +23,7 @@ class UserSettingsDecorator
|
|||
user.settings['delete_modal'] = delete_modal_preference
|
||||
user.settings['auto_play_gif'] = auto_play_gif_preference
|
||||
user.settings['system_font_ui'] = system_font_ui_preference
|
||||
user.settings['noindex'] = noindex_preference
|
||||
end
|
||||
|
||||
def merged_notification_emails
|
||||
|
@ -57,6 +58,10 @@ class UserSettingsDecorator
|
|||
boolean_cast_setting 'setting_auto_play_gif'
|
||||
end
|
||||
|
||||
def noindex_preference
|
||||
boolean_cast_setting 'setting_noindex'
|
||||
end
|
||||
|
||||
def boolean_cast_setting(key)
|
||||
settings[key] == '1'
|
||||
end
|
||||
|
|
|
@ -47,6 +47,7 @@ class Account < ApplicationRecord
|
|||
include AccountInteractions
|
||||
include Attachmentable
|
||||
include Remotable
|
||||
include EmojiHelper
|
||||
|
||||
# Local users
|
||||
has_one :user, inverse_of: :account
|
||||
|
@ -129,7 +130,7 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def save_with_optional_media!
|
||||
|
@ -240,9 +241,18 @@ class Account < ApplicationRecord
|
|||
|
||||
before_create :generate_keys
|
||||
before_validation :normalize_domain
|
||||
before_validation :prepare_contents, if: :local?
|
||||
|
||||
private
|
||||
|
||||
def prepare_contents
|
||||
display_name&.strip!
|
||||
note&.strip!
|
||||
|
||||
self.display_name = emojify(display_name)
|
||||
self.note = emojify(note)
|
||||
end
|
||||
|
||||
def generate_keys
|
||||
return unless local?
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Remotable
|
||||
include HttpHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
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
|
||||
|
||||
begin
|
||||
response = http_client.get(url)
|
||||
response = Request.new(:get, url).perform
|
||||
|
||||
return if response.code != 200
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# severity :integer default("silence")
|
||||
# reject_media :boolean
|
||||
# reject_media :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class DomainBlock < ApplicationRecord
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
# id :integer not null, primary key
|
||||
# account_id :integer not null
|
||||
# type :integer not null
|
||||
# approved :boolean
|
||||
# approved :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# 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