Merge upstream (#81)

th-downstream
kibigo! 7 years ago
commit f48f42598f

@ -22,7 +22,8 @@
{ {
"messagesDir": "./build/messages" "messagesDir": "./build/messages"
} }
] ],
"preval"
], ],
"env": { "env": {
"development": { "development": {

@ -31,6 +31,17 @@ PAPERCLIP_SECRET=
SECRET_KEY_BASE= SECRET_KEY_BASE=
OTP_SECRET= OTP_SECRET=
# VAPID keys (used for push notifications
# You can generate the keys using the following command (first is the private key, second is the public one)
# You should only generate this once per instance. If you later decide to change it, all push subscription will
# be invalidated, requiring the users to access the website again to resubscribe.
#
# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
#
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
VAPID_PRIVATE_KEY=
VAPID_PUBLIC_KEY=
# Registrations # Registrations
# Single user mode will disable registrations and redirect frontpage to the first profile # Single user mode will disable registrations and redirect frontpage to the first profile
# SINGLE_USER_MODE=true # SINGLE_USER_MODE=true

1
.gitignore vendored

@ -21,6 +21,7 @@ public/system
public/assets public/assets
public/packs public/packs
public/packs-test public/packs-test
public/sw.js
.env .env
.env.production .env.production
node_modules/ node_modules/

@ -6,3 +6,4 @@ plugins:
- last 2 versions - last 2 versions
- IE >= 11 - IE >= 11
- iOS >= 9 - iOS >= 9
postcss-object-fit-images: {}

@ -28,6 +28,7 @@ gem 'devise', '~> 4.2'
gem 'devise-two-factor', '~> 3.0' gem 'devise-two-factor', '~> 3.0'
gem 'doorkeeper', '~> 4.2' gem 'doorkeeper', '~> 4.2'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'gemoji', '~> 3.0'
gem 'goldfinger', '~> 1.2' gem 'goldfinger', '~> 1.2'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.5' gem 'redis-namespace', '~> 1.5'
@ -35,6 +36,7 @@ gem 'htmlentities', '~> 4.3'
gem 'http', '~> 2.2' gem 'http', '~> 2.2'
gem 'http_accept_language', '~> 2.1' gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 0.99' gem 'httplog', '~> 0.99'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.0' gem 'kaminari', '~> 1.0'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.1' gem 'mime-types', '~> 3.1'
@ -64,6 +66,7 @@ gem 'statsd-instrument', '~> 2.1'
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2017' gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 2.0' gem 'webpacker', '~> 2.0'
gem 'webpush'
group :development, :test do group :development, :test do
gem 'fabrication', '~> 2.16' gem 'fabrication', '~> 2.16'
@ -77,7 +80,7 @@ group :test do
gem 'capybara', '~> 2.14' gem 'capybara', '~> 2.14'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.7' gem 'faker', '~> 1.7'
gem 'microformats2', '~> 3.0' gem 'microformats', '~> 4.0'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0' gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.14', require: false gem 'simplecov', '~> 0.14', require: false

@ -163,6 +163,7 @@ GEM
fuubar (2.2.0) fuubar (2.2.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
gemoji (3.0.0)
globalid (0.4.0) globalid (0.4.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldfinger (1.2.0) goldfinger (1.2.0)
@ -181,6 +182,7 @@ GEM
hashdiff (0.3.4) hashdiff (0.3.4)
highline (1.7.8) highline (1.7.8)
hiredis (0.6.1) hiredis (0.6.1)
hkdf (0.3.0)
htmlentities (4.3.4) htmlentities (4.3.4)
http (2.2.2) http (2.2.2)
addressable (~> 2.3) addressable (~> 2.3)
@ -206,9 +208,11 @@ GEM
parser (>= 2.2.3.0) parser (>= 2.2.3.0)
rainbow (~> 2.2) rainbow (~> 2.2)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.0)
jmespath (1.3.1) jmespath (1.3.1)
json (2.1.0) json (2.1.0)
jsonapi-renderer (0.1.2) jsonapi-renderer (0.1.2)
jwt (1.5.6)
kaminari (1.0.1) kaminari (1.0.1)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.0.1) kaminari-actionview (= 1.0.1)
@ -239,7 +243,7 @@ GEM
mail (2.6.6) mail (2.6.6)
mime-types (>= 1.16, < 4) mime-types (>= 1.16, < 4)
method_source (0.8.2) method_source (0.8.2)
microformats2 (3.1.0) microformats (4.0.7)
json json
nokogiri nokogiri
mime-types (3.1) mime-types (3.1)
@ -475,6 +479,9 @@ GEM
activesupport (>= 4.2) activesupport (>= 4.2)
multi_json (~> 1.2) multi_json (~> 1.2)
railties (>= 4.2) railties (>= 4.2)
webpush (0.3.2)
hkdf (~> 0.2)
jwt
websocket-driver (0.6.5) websocket-driver (0.6.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2) websocket-extensions (0.1.2)
@ -513,6 +520,7 @@ DEPENDENCIES
faker (~> 1.7) faker (~> 1.7)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fuubar (~> 2.2) fuubar (~> 2.2)
gemoji (~> 3.0)
goldfinger (~> 1.2) goldfinger (~> 1.2)
hamlit-rails (~> 0.2) hamlit-rails (~> 0.2)
hiredis (~> 0.6) hiredis (~> 0.6)
@ -521,12 +529,13 @@ DEPENDENCIES
http_accept_language (~> 2.1) http_accept_language (~> 2.1)
httplog (~> 0.99) httplog (~> 0.99)
i18n-tasks (~> 0.9) i18n-tasks (~> 0.9)
idn-ruby
kaminari (~> 1.0) kaminari (~> 1.0)
letter_opener (~> 1.4) letter_opener (~> 1.4)
letter_opener_web (~> 1.3) letter_opener_web (~> 1.3)
link_header (~> 0.0) link_header (~> 0.0)
lograge (~> 0.5) lograge (~> 0.5)
microformats2 (~> 3.0) microformats (~> 4.0)
mime-types (~> 3.1) mime-types (~> 3.1)
nokogiri (~> 1.7) nokogiri (~> 1.7)
oj (~> 3.0) oj (~> 3.0)
@ -573,6 +582,7 @@ DEPENDENCIES
uglifier (~> 3.2) uglifier (~> 3.2)
webmock (~> 3.0) webmock (~> 3.0)
webpacker (~> 2.0) webpacker (~> 2.0)
webpush
RUBY VERSION RUBY VERSION
ruby 2.4.1p111 ruby 2.4.1p111

@ -2,6 +2,7 @@
class AccountsController < ApplicationController class AccountsController < ApplicationController
include AccountControllerConcern include AccountControllerConcern
include SignatureVerification
def show def show
respond_to do |format| respond_to do |format|
@ -15,7 +16,9 @@ class AccountsController < ApplicationController
render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a)) render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
end end
format.activitystreams2 format.json do
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
end
end end
end end

@ -0,0 +1,28 @@
# frozen_string_literal: true
class ActivityPub::OutboxesController < Api::BaseController
before_action :set_account
def show
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
private
def set_account
@account = Account.find_local!(params[:account_username])
end
def outbox_presenter
ActivityPub::CollectionPresenter.new(
id: account_outbox_url(@account),
type: :ordered,
current: account_outbox_url(@account),
size: @account.statuses_count,
items: @statuses
)
end
end

@ -1,27 +0,0 @@
# frozen_string_literal: true
class Api::ActivityPub::ActivitiesController < Api::BaseController
include Authorization
# before_action :set_follow, only: [:show_follow]
before_action :set_status, only: [:show_status]
respond_to :activitystreams2
# Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity.
def show_status
authorize @status, :show?
if @status.reblog?
render :show_status_announce
else
render :show_status_create
end
end
private
def set_status
@status = Status.find(params[:id])
end
end

@ -1,19 +0,0 @@
# frozen_string_literal: true
class Api::ActivityPub::NotesController < Api::BaseController
include Authorization
before_action :set_status
respond_to :activitystreams2
def show
authorize @status, :show?
end
private
def set_status
@status = Status.find(params[:id])
end
end

@ -1,69 +0,0 @@
# frozen_string_literal: true
class Api::ActivityPub::OutboxController < Api::BaseController
before_action :set_account
respond_to :activitystreams2
def show
if params[:max_id] || params[:since_id]
show_outbox_page
else
show_base_outbox
end
end
private
def show_base_outbox
@statuses = Status.as_outbox_timeline(@account)
@statuses = cache_collection(@statuses)
set_maps(@statuses)
set_first_last_page(@statuses)
render :show
end
def show_outbox_page
all_statuses = Status.as_outbox_timeline(@account)
@statuses = all_statuses.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
all_statuses = cache_collection(all_statuses)
@statuses = cache_collection(@statuses)
set_maps(@statuses)
set_first_last_page(all_statuses)
@next_page_url = api_activitypub_outbox_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
@prev_page_url = api_activitypub_outbox_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
@paginated = @next_page_url || @prev_page_url
@part_of_url = api_activitypub_outbox_url
set_pagination_headers(@next_page_url, @prev_page_url)
render :show_page
end
def cache_collection(raw)
super(raw, Status)
end
def set_account
@account = Account.find(params[:id])
end
def set_first_last_page(statuses) # rubocop:disable Style/AccessorMethodName
return if statuses.empty?
@first_page_url = api_activitypub_outbox_url(max_id: statuses.first.id + 1)
@last_page_url = api_activitypub_outbox_url(since_id: statuses.last.id - 1)
end
def pagination_params(core_params)
params.permit(:local, :limit).merge(core_params)
end
end

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::PushController < Api::BaseController class Api::PushController < Api::BaseController
include SignatureVerification
def update def update
response, status = process_push_request response, status = process_push_request
render plain: response, status: status render plain: response, status: status
@ -11,7 +13,7 @@ class Api::PushController < Api::BaseController
def process_push_request def process_push_request
case hub_mode case hub_mode
when 'subscribe' when 'subscribe'
Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds) Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain)
when 'unsubscribe' when 'unsubscribe'
Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback) Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback)
else else
@ -57,6 +59,10 @@ class Api::PushController < Api::BaseController
TagManager.instance.web_domain?(hub_topic_domain) TagManager.instance.web_domain?(hub_topic_domain)
end end
def verified_domain
return signed_request_account.domain if signed_request_account
end
def hub_topic_domain def hub_topic_domain
hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '') hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '')
end end

@ -42,7 +42,7 @@ class Api::SubscriptionsController < Api::BaseController
end end
def lease_seconds_or_default def lease_seconds_or_default
(params['hub.lease_seconds'] || 86_400).to_i.seconds (params['hub.lease_seconds'] || 1.day).to_i.seconds
end end
def set_account def set_account

@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
UnfavouriteWorker.perform_async(current_user.account_id, @status.id) UnfavouriteWorker.perform_async(current_user.account_id, @status.id)
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map)
end end
private private

@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
authorize status_for_destroy, :unreblog? authorize status_for_destroy, :unreblog?
RemovalWorker.perform_async(status_for_destroy.id) RemovalWorker.perform_async(status_for_destroy.id)
render json: @status, serializer: REST::StatusSerializer render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
end end
private private

@ -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

@ -0,0 +1,87 @@
# frozen_string_literal: true
# Implemented according to HTTP signatures (Draft 6)
# <https://tools.ietf.org/html/draft-cavage-http-signatures-06>
module SignatureVerification
extend ActiveSupport::Concern
def signed_request?
request.headers['Signature'].present?
end
def signed_request_account
return @signed_request_account if defined?(@signed_request_account)
unless signed_request?
@signed_request_account = nil
return
end
raw_signature = request.headers['Signature']
signature_params = {}
raw_signature.split(',').each do |part|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
next if parsed_parts.nil? || parsed_parts.size != 3
signature_params[parsed_parts[1]] = parsed_parts[2]
end
if incompatible_signature?(signature_params)
@signed_request_account = nil
return
end
account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
if account.nil?
@signed_request_account = nil
return
end
signature = Base64.decode64(signature_params['signature'])
compare_signed_string = build_signed_string(signature_params['headers'])
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account
@signed_request_account
else
@signed_request_account = nil
end
end
private
def build_signed_string(signed_headers)
signed_headers = 'date' if signed_headers.blank?
signed_headers.split(' ').map do |signed_header|
if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
else
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
end
end.join("\n")
end
def matches_time_window?
begin
time_sent = DateTime.httpdate(request.headers['Date'])
rescue ArgumentError
return false
end
(Time.now.utc - time_sent).abs <= 30
end
def to_header_name(name)
name.split(/-/).map(&:capitalize).join('-')
end
def incompatible_signature?(signature_params)
signature_params['keyId'].blank? ||
signature_params['signature'].blank? ||
signature_params['algorithm'].blank? ||
signature_params['algorithm'] != 'rsa-sha256' ||
!signature_params['keyId'].start_with?('acct:')
end
end

@ -5,5 +5,25 @@ class FollowerAccountsController < ApplicationController
def index def index
@follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
respond_to do |format|
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account),
type: :ordered,
current: account_followers_url(@account),
size: @account.followers_count,
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }
)
end end
end end

@ -5,5 +5,25 @@ class FollowingAccountsController < ApplicationController
def index def index
@follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
respond_to do |format|
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_following_index_url(@account),
type: :ordered,
current: account_following_index_url(@account),
size: @account.following_count,
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }
)
end end
end end

@ -22,6 +22,7 @@ class HomeController < ApplicationController
def initial_state_params def initial_state_params
{ {
settings: Web::Setting.find_by(user: current_user)&.data || {}, settings: Web::Setting.find_by(user: current_user)&.data || {},
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account, current_account: current_account,
token: current_session.token, token: current_session.token,
admin: Account.find_local(Setting.site_contact_username), admin: Account.find_local(Setting.site_contact_username),

@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController
:setting_delete_modal, :setting_delete_modal,
:setting_auto_play_gif, :setting_auto_play_gif,
:setting_system_font_ui, :setting_system_font_ui,
:setting_noindex,
notification_emails: %i(follow follow_request reblog favourite mention digest), notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following) interactions: %i(must_be_follower must_be_following)
) )

@ -11,12 +11,24 @@ class StatusesController < ApplicationController
before_action :check_account_suspension before_action :check_account_suspension
def show def show
respond_to do |format|
format.html do
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] @ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
@descendants = cache_collection(@status.descendants(current_account), Status) @descendants = cache_collection(@status.descendants(current_account), Status)
render 'stream_entries/show' render 'stream_entries/show'
end 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 private
def set_account def set_account

@ -2,6 +2,7 @@
class StreamEntriesController < ApplicationController class StreamEntriesController < ApplicationController
include Authorization include Authorization
include SignatureVerification
layout 'public' layout 'public'

@ -5,7 +5,27 @@ class TagsController < ApplicationController
def show def show
@tag = Tag.find_by!(name: params[:id].downcase) @tag = Tag.find_by!(name: params[:id].downcase)
@statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
@statuses = cache_collection(@statuses, Status) @statuses = cache_collection(@statuses, Status)
respond_to do |format|
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: tag_url(@tag),
type: :ordered,
current: tag_url(@tag),
size: @tag.statuses.count,
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
)
end end
end end

@ -1,8 +0,0 @@
# frozen_string_literal: true
module Activitystreams2BuilderHelper
# Gets a usable name for an account, using display name or username.
def account_name(account)
account.display_name.presence || account.username
end
end

@ -0,0 +1,19 @@
# frozen_string_literal: true
module EmojiHelper
EMOJI_PATTERN = /(?<=[^[:alnum:]:]|\n|^):([\w+-]+):(?=[^[:alnum:]:]|$)/x
def emojify(text)
return text if text.blank?
text.gsub(EMOJI_PATTERN) do |match|
emoji = Emoji.find_by_alias($1) # rubocop:disable Rails/DynamicFindBy,Style/PerlBackrefs
if emoji
emoji.raw
else
match
end
end
end
end

@ -1,17 +0,0 @@
# frozen_string_literal: true
module HttpHelper
def http_client(options = {})
timeout = { write: 10, connect: 10, read: 10 }.merge(options)
HTTP.headers(user_agent: user_agent)
.timeout(:per_operation, timeout)
.follow
end
private
def user_agent
@user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +http://#{Rails.configuration.x.local_domain}/)"
end
end

@ -2,8 +2,6 @@ import api from '../api';
import { updateTimeline } from './timelines'; import { updateTimeline } from './timelines';
import * as emojione from 'emojione';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
@ -74,10 +72,12 @@ export function mentionCompose(account, router) {
export function submitCompose() { export function submitCompose() {
return function (dispatch, getState) { return function (dispatch, getState) {
let status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], '')); const status = getState().getIn(['compose', 'text'], '');
if (!status || !status.length) { if (!status || !status.length) {
return; return;
} }
dispatch(submitComposeRequest()); dispatch(submitComposeRequest());
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
status = status + ' 👁️'; status = status + ' 👁️';

@ -0,0 +1,52 @@
import axios from 'axios';
export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
export function setBrowserSupport (value) {
return {
type: SET_BROWSER_SUPPORT,
value,
};
}
export function setSubscription (subscription) {
return {
type: SET_SUBSCRIPTION,
subscription,
};
}
export function clearSubscription () {
return {
type: CLEAR_SUBSCRIPTION,
};
}
export function changeAlerts(key, value) {
return dispatch => {
dispatch({
type: ALERTS_CHANGE,
key,
value,
});
dispatch(saveSettings());
};
}
export function saveSettings() {
return (_, getState) => {
const state = getState().get('push_notifications');
const subscription = state.get('subscription');
const alerts = state.get('alerts');
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
data: {
alerts,
},
});
};
}

@ -5,6 +5,8 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
static propTypes = { static propTypes = {
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
time: PropTypes.number, time: PropTypes.number,
controls: PropTypes.bool.isRequired, controls: PropTypes.bool.isRequired,
muted: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired,
@ -30,7 +32,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
render () { render () {
return ( return (
<div className='extended-video-player'> <div className='extended-video-player' style={{ width: this.props.width, height: this.props.height }}>
<video <video
ref={this.setRef} ref={this.setRef}
src={this.props.src} src={this.props.src}

@ -6,11 +6,18 @@ export default class LoadMore extends React.PureComponent {
static propTypes = { static propTypes = {
onClick: PropTypes.func, onClick: PropTypes.func,
visible: PropTypes.bool,
}
static defaultProps = {
visible: true,
} }
render() { render() {
const { visible } = this.props;
return ( return (
<button className='load-more' onClick={this.props.onClick}> <button className='load-more' disabled={!visible} style={{ opacity: visible ? 1 : 0 }} onClick={this.props.onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' /> <FormattedMessage id='status.load_more' defaultMessage='Load more' />
</button> </button>
); );

@ -101,13 +101,9 @@ export default class StatusList extends ImmutablePureComponent {
render () { render () {
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
let loadMore = null; const loadMore = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />;
let scrollableArea = null; let scrollableArea = null;
if (!isLoading && statusIds.size > 0 && hasMore) {
loadMore = <LoadMore onClick={this.handleLoadMore} />;
}
if (isLoading || statusIds.size > 0 || !emptyMessage) { if (isLoading || statusIds.size > 0 || !emptyMessage) {
scrollableArea = ( scrollableArea = (
<div className='scrollable' ref={this.setRef}> <div className='scrollable' ref={this.setRef}>

@ -1,49 +1,28 @@
import emojione from 'emojione'; import { unicodeToFilename } from './emojione_light';
import Trie from 'substring-trie'; import Trie from 'substring-trie';
const mappedUnicode = emojione.mapUnicodeToShort(); const trie = new Trie(Object.keys(unicodeToFilename));
const trie = new Trie(Object.keys(emojione.jsEscapeMap));
function emojify(str) { function emojify(str) {
// This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.) // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
// and replacing valid shortnames like :smile: and :wink: as well as unicode strings // and replacing valid unicode strings
// that _aren't_ within tags with an <img> version. // that _aren't_ within tags with an <img> version.
// The goal is to be the same as an emojione.regShortNames/regUnicode replacement, but faster. // The goal is to be the same as an emojione.regUnicode replacement, but faster.
let i = -1; let i = -1;
let insideTag = false; let insideTag = false;
let insideShortname = false;
let shortnameStartIndex = -1;
let match; let match;
while (++i < str.length) { while (++i < str.length) {
const char = str.charAt(i); const char = str.charAt(i);
if (insideShortname && char === ':') { if (insideTag && char === '>') {
const shortname = str.substring(shortnameStartIndex, i + 1);
if (shortname in emojione.emojioneList) {
const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
const alt = emojione.convert(unicode.toUpperCase());
const replacement = `<img draggable="false" class="emojione" alt="${alt}" title="${shortname}" src="/emoji/${unicode}.svg" />`;
str = str.substring(0, shortnameStartIndex) + replacement + str.substring(i + 1);
i += (replacement.length - shortname.length - 1); // jump ahead the length we've added to the string
} else {
i--; // stray colon, try again
}
insideShortname = false;
} else if (insideTag && char === '>') {
insideTag = false; insideTag = false;
} else if (char === '<') { } else if (char === '<') {
insideTag = true; insideTag = true;
insideShortname = false;
} else if (!insideTag && char === ':') {
insideShortname = true;
shortnameStartIndex = i;
} else if (!insideTag && (match = trie.search(str.substring(i)))) { } else if (!insideTag && (match = trie.search(str.substring(i)))) {
const unicodeStr = match; const unicodeStr = match;
if (unicodeStr in emojione.jsEscapeMap) { if (unicodeStr in unicodeToFilename) {
const unicode = emojione.jsEscapeMap[unicodeStr]; const filename = unicodeToFilename[unicodeStr];
const short = mappedUnicode[unicode]; const alt = unicodeStr;
const filename = emojione.emojioneList[short].fname; const replacement = `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
const alt = emojione.convert(unicode.toUpperCase());
const replacement = `<img draggable="false" class="emojione" alt="${alt}" title="${short}" src="/emoji/${filename}.svg" />`;
str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length); str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
} }

@ -0,0 +1,11 @@
// @preval
// Force tree shaking on emojione by exposing just a subset of its functionality
const emojione = require('emojione');
const mappedUnicode = emojione.mapUnicodeToShort();
module.exports.unicodeToFilename = Object.keys(emojione.jsEscapeMap)
.map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
.map(([unicodeStr, shortCode]) => ({ [unicodeStr]: emojione.emojioneList[shortCode].fname }))
.reduce((x, y) => Object.assign(x, y), { });

@ -1,2 +1,5 @@
import 'intersection-observer'; import 'intersection-observer';
import 'requestidlecallback'; import 'requestidlecallback';
import objectFitImages from 'object-fit-images';
objectFitImages();

@ -140,7 +140,8 @@ export default class ComposeForm extends ImmutablePureComponent {
handleEmojiPick = (data) => { handleEmojiPick = (data) => {
const position = this.autosuggestTextarea.textarea.selectionStart; const position = this.autosuggestTextarea.textarea.selectionStart;
this._restoreCaret = position + data.shortname.length + 1; const emojiChar = String.fromCodePoint(parseInt(data.unicode, 16));
this._restoreCaret = position + emojiChar.length + 1;
this.props.onPickEmoji(position, data); this.props.onPickEmoji(position, data);
} }

@ -109,11 +109,12 @@ export default class EmojiPickerDropdown extends React.PureComponent {
<Dropdown ref={this.setRef} className='emoji-picker__dropdown' onShow={this.onShowDropdown} onHide={this.onHideDropdown}> <Dropdown ref={this.setRef} className='emoji-picker__dropdown' onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
<DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}> <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)}>
<img <img
draggable='false'
className={`emojione ${active && loading ? 'pulse-loading' : ''}`} className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
alt='🙂' src='/emoji/1f602.svg' alt='🙂'
src='/emoji/1f602.svg'
/> />
</DropdownTrigger> </DropdownTrigger>
<DropdownContent className='dropdown__left'> <DropdownContent className='dropdown__left'>
{ {
this.state.active && !this.state.loading && this.state.active && !this.state.loading &&

@ -2,11 +2,11 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import StatusList from '../../components/status_list'; import StatusList from '../../components/status_list';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@ -16,8 +16,6 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']), statusIds: state.getIn(['status_lists', 'favourites', 'items']),
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
me: state.getIn(['meta', 'me']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@ -27,34 +25,64 @@ export default class Favourites extends ImmutablePureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired, statusIds: ImmutablePropTypes.list.isRequired,
loaded: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
me: PropTypes.number.isRequired, columnId: PropTypes.string,
multiColumn: PropTypes.bool,
}; };
componentWillMount () { componentWillMount () {
this.props.dispatch(fetchFavouritedStatuses()); this.props.dispatch(fetchFavouritedStatuses());
} }
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('FAVOURITES', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
handleScrollToBottom = () => { handleScrollToBottom = () => {
this.props.dispatch(expandFavouritedStatuses()); this.props.dispatch(expandFavouritedStatuses());
} }
render () { render () {
const { loaded, intl } = this.props; const { intl, statusIds, columnId, multiColumn } = this.props;
const pinned = !!columnId;
if (!loaded) {
return ( return (
<Column> <Column ref={this.setRef}>
<LoadingIndicator /> <ColumnHeader
</Column> icon='star'
); title={intl.formatMessage(messages.heading)}
} onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
return ( <StatusList
<Column icon='star' heading={intl.formatMessage(messages.heading)}> trackScroll={!pinned}
<ColumnBackButtonSlim /> statusIds={statusIds}
<StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} /> scrollKey={`favourited_statuses-${columnId}`}
onScrollToBottom={this.handleScrollToBottom}
/>
</Column> </Column>
); );
} }

@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent {
static propTypes = { static propTypes = {
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
pushSettings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired,
}; };
onPushChange = (key, checked) => {
this.props.onChange(['push', ...key], checked);
}
render () { render () {
const { settings, onChange, onClear } = this.props; const { settings, pushSettings, onChange, onClear } = this.props;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
return ( return (
<div> <div>
<div className='column-settings__row'> <div className='column-settings__row'>
@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div> </div>
@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div> </div>
@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div> </div>
@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div> </div>

@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent {
settings: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired,
settingKey: PropTypes.array.isRequired, settingKey: PropTypes.array.isRequired,
label: PropTypes.node.isRequired, label: PropTypes.node.isRequired,
meta: PropTypes.node,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
} }
@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent {
} }
render () { render () {
const { prefix, settings, settingKey, label } = this.props; const { prefix, settings, settingKey, label, meta } = this.props;
const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
return ( return (
<div className='setting-toggle'> <div className='setting-toggle'>
<Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} /> <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} />
<label htmlFor={id} className='setting-toggle__label'>{label}</label> <label htmlFor={id} className='setting-toggle__label'>{label}</label>
{meta && <span className='setting-meta__label'>{meta}</span>}
</div> </div>
); );
} }

@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettings from '../components/column_settings'; import ColumnSettings from '../components/column_settings';
import { changeSetting, saveSettings } from '../../../actions/settings'; import { changeSetting, saveSettings } from '../../../actions/settings';
import { clearNotifications } from '../../../actions/notifications'; import { clearNotifications } from '../../../actions/notifications';
import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
const messages = defineMessages({ const messages = defineMessages({
@ -12,16 +13,22 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
settings: state.getIn(['settings', 'notifications']), settings: state.getIn(['settings', 'notifications']),
pushSettings: state.get('push_notifications'),
}); });
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (key, checked) { onChange (key, checked) {
if (key[0] === 'push') {
dispatch(changePushNotifications(key.slice(1), checked));
} else {
dispatch(changeSetting(['notifications', ...key], checked)); dispatch(changeSetting(['notifications', ...key], checked));
}
}, },
onSave () { onSave () {
dispatch(saveSettings()); dispatch(saveSettings());
dispatch(savePushNotificationSettings());
}, },
onClear () { onClear () {

@ -9,7 +9,7 @@ import { links, getIndex, getLink } from './tabs_bar';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading'; import ColumnLoading from './column_loading';
import BundleColumnError from './bundle_column_error'; import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline } from '../../ui/util/async-components'; import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
const componentMap = { const componentMap = {
'COMPOSE': Compose, 'COMPOSE': Compose,
@ -18,6 +18,7 @@ const componentMap = {
'PUBLIC': PublicTimeline, 'PUBLIC': PublicTimeline,
'COMMUNITY': CommunityTimeline, 'COMMUNITY': CommunityTimeline,
'HASHTAG': HashtagTimeline, 'HASHTAG': HashtagTimeline,
'FAVOURITES': FavouritedStatuses,
}; };
export default class ColumnsArea extends ImmutablePureComponent { export default class ColumnsArea extends ImmutablePureComponent {
@ -32,12 +33,33 @@ export default class ColumnsArea extends ImmutablePureComponent {
children: PropTypes.node, children: PropTypes.node,
}; };
state = {
shouldAnimate: false,
}
componentWillReceiveProps() {
this.setState({ shouldAnimate: false });
}
componentDidMount() {
this.lastIndex = getIndex(this.context.router.history.location.pathname);
this.setState({ shouldAnimate: true });
}
componentDidUpdate() {
this.lastIndex = getIndex(this.context.router.history.location.pathname);
this.setState({ shouldAnimate: true });
}
handleSwipe = (index) => { handleSwipe = (index) => {
window.requestAnimationFrame(() => { this.pendingIndex = index;
window.requestAnimationFrame(() => { }
this.context.router.history.push(getLink(index));
}); handleAnimationEnd = () => {
}); if (typeof this.pendingIndex === 'number') {
this.context.router.history.push(getLink(this.pendingIndex));
this.pendingIndex = null;
}
} }
renderView = (link, index) => { renderView = (link, index) => {
@ -66,12 +88,14 @@ export default class ColumnsArea extends ImmutablePureComponent {
render () { render () {
const { columns, children, singleColumn } = this.props; const { columns, children, singleColumn } = this.props;
const { shouldAnimate } = this.state;
const columnIndex = getIndex(this.context.router.history.location.pathname); const columnIndex = getIndex(this.context.router.history.location.pathname);
this.pendingIndex = null;
if (singleColumn) { if (singleColumn) {
return columnIndex !== -1 ? ( return columnIndex !== -1 ? (
<ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} animateTransitions={false} style={{ height: '100%' }}> <ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
{links.map(this.renderView)} {links.map(this.renderView)}
</ReactSwipeableViews> </ReactSwipeableViews>
) : <div className='columns-area'>{children}</div>; ) : <div className='columns-area'>{children}</div>;

@ -65,8 +65,6 @@ export default class MediaModal extends ImmutablePureComponent {
const { media, intl, onClose } = this.props; const { media, intl, onClose } = this.props;
const index = this.getIndex(); const index = this.getIndex();
const attachment = media.get(index);
const url = attachment.get('url');
let leftNav, rightNav, content; let leftNav, rightNav, content;
@ -77,17 +75,19 @@ export default class MediaModal extends ImmutablePureComponent {
rightNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; rightNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
} }
if (attachment.get('type') === 'image') {
content = media.map((image) => { content = media.map((image) => {
const width = image.getIn(['meta', 'original', 'width']) || null; const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null; const height = image.getIn(['meta', 'original', 'height']) || null;
if (image.get('type') === 'image') {
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />; return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />;
}).toArray(); } else if (image.get('type') === 'gifv') {
} else if (attachment.get('type') === 'gifv') { return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />;
content = <ExtendedVideoPlayer src={url} muted controls={false} />;
} }
return null;
}).toArray();
return ( return (
<div className='modal-root__modal media-modal'> <div className='modal-root__modal media-modal'>
{leftNav} {leftNav}

@ -56,12 +56,6 @@ export default class ModalRoot extends React.PureComponent {
return { opacity: spring(0), scale: spring(0.98) }; return { opacity: spring(0), scale: spring(0.98) };
} }
renderModal = (SpecificComponent) => {
const { props, onClose } = this.props;
return <SpecificComponent {...props} onClose={onClose} />;
}
renderLoading = () => { renderLoading = () => {
return <ModalLoading />; return <ModalLoading />;
} }
@ -97,7 +91,9 @@ export default class ModalRoot extends React.PureComponent {
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
<div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>{this.renderModal}</BundleContainer> <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
</BundleContainer>
</div> </div>
</div> </div>
))} ))}

@ -20,11 +20,12 @@ function loadPolyfills() {
); );
// Latest version of Firefox and Safari do not have IntersectionObserver. // Latest version of Firefox and Safari do not have IntersectionObserver.
// Edge does not have requestIdleCallback. // Edge does not have requestIdleCallback and object-fit CSS property.
// This avoids shipping them all the polyfills. // This avoids shipping them all the polyfills.
const needsExtraPolyfills = !( const needsExtraPolyfills = !(
window.IntersectionObserver && window.IntersectionObserver &&
window.requestIdleCallback window.requestIdleCallback &&
'object-fit' in (new Image()).style
); );
return Promise.all([ return Promise.all([

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "المُفَضَّلة :", "notifications.column_settings.favourite": "المُفَضَّلة :",
"notifications.column_settings.follow": "متابعُون جُدُد :", "notifications.column_settings.follow": "متابعُون جُدُد :",
"notifications.column_settings.mention": "الإشارات :", "notifications.column_settings.mention": "الإشارات :",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "الترقيّات:", "notifications.column_settings.reblog": "الترقيّات:",
"notifications.column_settings.show": "إعرِضها في عمود", "notifications.column_settings.show": "إعرِضها في عمود",
"notifications.column_settings.sound": "أصدر صوتا", "notifications.column_settings.sound": "أصدر صوتا",
@ -147,6 +149,7 @@
"report.target": "إبلاغ", "report.target": "إبلاغ",
"search.placeholder": "ابحث", "search.placeholder": "ابحث",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "تعذرت ترقية هذا المنشور", "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
"status.delete": "إحذف", "status.delete": "إحذف",
"status.favourite": "أضف إلى المفضلة", "status.favourite": "أضف إلى المفضلة",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Предпочитани:", "notifications.column_settings.favourite": "Предпочитани:",
"notifications.column_settings.follow": "Нови последователи:", "notifications.column_settings.follow": "Нови последователи:",
"notifications.column_settings.mention": "Споменавания:", "notifications.column_settings.mention": "Споменавания:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Споделяния:", "notifications.column_settings.reblog": "Споделяния:",
"notifications.column_settings.show": "Покажи в колона", "notifications.column_settings.show": "Покажи в колона",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
@ -147,6 +149,7 @@
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Търсене", "search.placeholder": "Търсене",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Изтриване", "status.delete": "Изтриване",
"status.favourite": "Предпочитани", "status.favourite": "Предпочитани",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favorits:", "notifications.column_settings.favourite": "Favorits:",
"notifications.column_settings.follow": "Nous seguidors:", "notifications.column_settings.follow": "Nous seguidors:",
"notifications.column_settings.mention": "Mencions:", "notifications.column_settings.mention": "Mencions:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Mostrar en la columna", "notifications.column_settings.show": "Mostrar en la columna",
"notifications.column_settings.sound": "Reproduïr so", "notifications.column_settings.sound": "Reproduïr so",
@ -147,6 +149,7 @@
"report.target": "Informes", "report.target": "Informes",
"search.placeholder": "Cercar", "search.placeholder": "Cercar",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Aquesta publicació no pot ser retootejada", "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
"status.delete": "Esborrar", "status.delete": "Esborrar",
"status.favourite": "Favorit", "status.favourite": "Favorit",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favorisierungen:", "notifications.column_settings.favourite": "Favorisierungen:",
"notifications.column_settings.follow": "Neue Folgende:", "notifications.column_settings.follow": "Neue Folgende:",
"notifications.column_settings.mention": "Erwähnungen:", "notifications.column_settings.mention": "Erwähnungen:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Geteilte Beiträge:", "notifications.column_settings.reblog": "Geteilte Beiträge:",
"notifications.column_settings.show": "In der Spalte anzeigen", "notifications.column_settings.show": "In der Spalte anzeigen",
"notifications.column_settings.sound": "Ton abspielen", "notifications.column_settings.sound": "Ton abspielen",
@ -147,6 +149,7 @@
"report.target": "Melden", "report.target": "Melden",
"search.placeholder": "Suche", "search.placeholder": "Suche",
"search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Löschen", "status.delete": "Löschen",
"status.favourite": "Favorisieren", "status.favourite": "Favorisieren",

@ -889,6 +889,14 @@
"defaultMessage": "Play sound", "defaultMessage": "Play sound",
"id": "notifications.column_settings.sound" "id": "notifications.column_settings.sound"
}, },
{
"defaultMessage": "Push notifications",
"id": "notifications.column_settings.push"
},
{
"defaultMessage": "This device",
"id": "notifications.column_settings.push_meta"
},
{ {
"defaultMessage": "New followers:", "defaultMessage": "New followers:",
"id": "notifications.column_settings.follow" "id": "notifications.column_settings.follow"
@ -964,6 +972,15 @@
], ],
"path": "app/javascript/mastodon/features/public_timeline/index.json" "path": "app/javascript/mastodon/features/public_timeline/index.json"
}, },
{
"descriptors": [
{
"defaultMessage": "A look inside...",
"id": "standalone.public_title"
}
],
"path": "app/javascript/mastodon/features/standalone/public_timeline/index.json"
},
{ {
"descriptors": [ "descriptors": [
{ {

@ -114,6 +114,8 @@
"notifications.column_settings.favourite": "Favourites:", "notifications.column_settings.favourite": "Favourites:",
"notifications.column_settings.follow": "New followers:", "notifications.column_settings.follow": "New followers:",
"notifications.column_settings.mention": "Mentions:", "notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Show in column", "notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
@ -170,6 +172,7 @@
"settings.media_fullwidth": "Full-width media previews", "settings.media_fullwidth": "Full-width media previews",
"settings.preferences": "User preferences", "settings.preferences": "User preferences",
"settings.wide_view": "Wide view (Desktop mode only)", "settings.wide_view": "Wide view (Desktop mode only)",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.collapse": "Collapse", "status.collapse": "Collapse",
"status.delete": "Delete", "status.delete": "Delete",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favoroj:", "notifications.column_settings.favourite": "Favoroj:",
"notifications.column_settings.follow": "Novaj sekvantoj:", "notifications.column_settings.follow": "Novaj sekvantoj:",
"notifications.column_settings.mention": "Mencioj:", "notifications.column_settings.mention": "Mencioj:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Diskonigoj:", "notifications.column_settings.reblog": "Diskonigoj:",
"notifications.column_settings.show": "Montri en kolono", "notifications.column_settings.show": "Montri en kolono",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
@ -147,6 +149,7 @@
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Serĉi", "search.placeholder": "Serĉi",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Forigi", "status.delete": "Forigi",
"status.favourite": "Favori", "status.favourite": "Favori",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favoritos:", "notifications.column_settings.favourite": "Favoritos:",
"notifications.column_settings.follow": "Nuevos seguidores:", "notifications.column_settings.follow": "Nuevos seguidores:",
"notifications.column_settings.mention": "Menciones:", "notifications.column_settings.mention": "Menciones:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Retoots:", "notifications.column_settings.reblog": "Retoots:",
"notifications.column_settings.show": "Mostrar en columna", "notifications.column_settings.show": "Mostrar en columna",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
@ -147,6 +149,7 @@
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Buscar", "search.placeholder": "Buscar",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Borrar", "status.delete": "Borrar",
"status.favourite": "Favorito", "status.favourite": "Favorito",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "پسندیده‌ها:", "notifications.column_settings.favourite": "پسندیده‌ها:",
"notifications.column_settings.follow": "پیگیران تازه:", "notifications.column_settings.follow": "پیگیران تازه:",
"notifications.column_settings.mention": "نام‌بردن‌ها:", "notifications.column_settings.mention": "نام‌بردن‌ها:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "بازبوق‌ها:", "notifications.column_settings.reblog": "بازبوق‌ها:",
"notifications.column_settings.show": "نمایش در ستون", "notifications.column_settings.show": "نمایش در ستون",
"notifications.column_settings.sound": "پخش صدا", "notifications.column_settings.sound": "پخش صدا",
@ -147,6 +149,7 @@
"report.target": "گزارش‌دادن", "report.target": "گزارش‌دادن",
"search.placeholder": "جستجو", "search.placeholder": "جستجو",
"search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}", "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید", "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
"status.delete": "پاک‌کردن", "status.delete": "پاک‌کردن",
"status.favourite": "پسندیدن", "status.favourite": "پسندیدن",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Tykkäyksiä:", "notifications.column_settings.favourite": "Tykkäyksiä:",
"notifications.column_settings.follow": "Uusia seuraajia:", "notifications.column_settings.follow": "Uusia seuraajia:",
"notifications.column_settings.mention": "Mainintoja:", "notifications.column_settings.mention": "Mainintoja:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Buusteja:", "notifications.column_settings.reblog": "Buusteja:",
"notifications.column_settings.show": "Näytä sarakkeessa", "notifications.column_settings.show": "Näytä sarakkeessa",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
@ -147,6 +149,7 @@
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Hae", "search.placeholder": "Hae",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Poista", "status.delete": "Poista",
"status.favourite": "Tykkää", "status.favourite": "Tykkää",

@ -29,7 +29,7 @@
"column.favourites": "Favoris", "column.favourites": "Favoris",
"column.follow_requests": "Demandes de suivi", "column.follow_requests": "Demandes de suivi",
"column.home": "Accueil", "column.home": "Accueil",
"column.mutes": "Comptes silencés", "column.mutes": "Comptes masqués",
"column.notifications": "Notifications", "column.notifications": "Notifications",
"column.public": "Fil public global", "column.public": "Fil public global",
"column_back_button.label": "Retour", "column_back_button.label": "Retour",
@ -52,9 +52,9 @@
"confirmations.delete.confirm": "Supprimer", "confirmations.delete.confirm": "Supprimer",
"confirmations.delete.message": "Confirmez vous la suppression de ce pouet?", "confirmations.delete.message": "Confirmez vous la suppression de ce pouet?",
"confirmations.domain_block.confirm": "Masquer le domaine entier", "confirmations.domain_block.confirm": "Masquer le domaine entier",
"confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier? Dans la plupart des cas, quelques blocages ou silenciations ciblés sont suffisants et préférables.", "confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables.",
"confirmations.mute.confirm": "Silencer", "confirmations.mute.confirm": "Masquer",
"confirmations.mute.message": "Confirmez vous la silenciation {name}?", "confirmations.mute.message": "Confirmez vous le masquage de {name}?",
"emoji_button.activity": "Activités", "emoji_button.activity": "Activités",
"emoji_button.flags": "Drapeaux", "emoji_button.flags": "Drapeaux",
"emoji_button.food": "Boire et manger", "emoji_button.food": "Boire et manger",
@ -96,7 +96,7 @@
"navigation_bar.follow_requests": "Demandes de suivi", "navigation_bar.follow_requests": "Demandes de suivi",
"navigation_bar.info": "Plus dinformations", "navigation_bar.info": "Plus dinformations",
"navigation_bar.logout": "Déconnexion", "navigation_bar.logout": "Déconnexion",
"navigation_bar.mutes": "Comptes silencés", "navigation_bar.mutes": "Comptes masqués",
"navigation_bar.preferences": "Préférences", "navigation_bar.preferences": "Préférences",
"navigation_bar.public_timeline": "Fil public global", "navigation_bar.public_timeline": "Fil public global",
"notification.favourite": "{name} a ajouté à ses favoris:", "notification.favourite": "{name} a ajouté à ses favoris:",
@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favoris:", "notifications.column_settings.favourite": "Favoris:",
"notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s:", "notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s:",
"notifications.column_settings.mention": "Mentions:", "notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Partages:", "notifications.column_settings.reblog": "Partages:",
"notifications.column_settings.show": "Afficher dans la colonne", "notifications.column_settings.show": "Afficher dans la colonne",
"notifications.column_settings.sound": "Émettre un son", "notifications.column_settings.sound": "Émettre un son",
@ -147,6 +149,7 @@
"report.target": "Signalement", "report.target": "Signalement",
"search.placeholder": "Rechercher", "search.placeholder": "Rechercher",
"search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Cette publication ne peut être boostée", "status.cannot_reblog": "Cette publication ne peut être boostée",
"status.delete": "Effacer", "status.delete": "Effacer",
"status.favourite": "Ajouter aux favoris", "status.favourite": "Ajouter aux favoris",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "מחובבים:", "notifications.column_settings.favourite": "מחובבים:",
"notifications.column_settings.follow": "עוקבים חדשים:", "notifications.column_settings.follow": "עוקבים חדשים:",
"notifications.column_settings.mention": "פניות:", "notifications.column_settings.mention": "פניות:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "הדהודים:", "notifications.column_settings.reblog": "הדהודים:",
"notifications.column_settings.show": "הצגה בטור", "notifications.column_settings.show": "הצגה בטור",
"notifications.column_settings.sound": "שמע מופעל", "notifications.column_settings.sound": "שמע מופעל",
@ -147,6 +149,7 @@
"report.target": "דיווח", "report.target": "דיווח",
"search.placeholder": "חיפוש", "search.placeholder": "חיפוש",
"search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}", "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "לא ניתן להדהד הודעה זו", "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
"status.delete": "מחיקה", "status.delete": "מחיקה",
"status.favourite": "חיבוב", "status.favourite": "חיבוב",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favoriti:", "notifications.column_settings.favourite": "Favoriti:",
"notifications.column_settings.follow": "Novi sljedbenici:", "notifications.column_settings.follow": "Novi sljedbenici:",
"notifications.column_settings.mention": "Spominjanja:", "notifications.column_settings.mention": "Spominjanja:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Prikaži u stupcu", "notifications.column_settings.show": "Prikaži u stupcu",
"notifications.column_settings.sound": "Sviraj zvuk", "notifications.column_settings.sound": "Sviraj zvuk",
@ -147,6 +149,7 @@
"report.target": "Prijavljivanje", "report.target": "Prijavljivanje",
"search.placeholder": "Traži", "search.placeholder": "Traži",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Ovaj post ne može biti podignut", "status.cannot_reblog": "Ovaj post ne može biti podignut",
"status.delete": "Obriši", "status.delete": "Obriši",
"status.favourite": "Označi omiljenim", "status.favourite": "Označi omiljenim",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favourites:", "notifications.column_settings.favourite": "Favourites:",
"notifications.column_settings.follow": "New followers:", "notifications.column_settings.follow": "New followers:",
"notifications.column_settings.mention": "Mentions:", "notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Show in column", "notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
@ -147,6 +149,7 @@
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Keresés", "search.placeholder": "Keresés",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Törlés", "status.delete": "Törlés",
"status.favourite": "Kedvenc", "status.favourite": "Kedvenc",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favorit:", "notifications.column_settings.favourite": "Favorit:",
"notifications.column_settings.follow": "Pengikut baru:", "notifications.column_settings.follow": "Pengikut baru:",
"notifications.column_settings.mention": "Balasan:", "notifications.column_settings.mention": "Balasan:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boost:", "notifications.column_settings.reblog": "Boost:",
"notifications.column_settings.show": "Tampilkan dalam kolom", "notifications.column_settings.show": "Tampilkan dalam kolom",
"notifications.column_settings.sound": "Mainkan suara", "notifications.column_settings.sound": "Mainkan suara",
@ -147,6 +149,7 @@
"report.target": "Melaporkan", "report.target": "Melaporkan",
"search.placeholder": "Pencarian", "search.placeholder": "Pencarian",
"search_results.total": "{count} {count, plural, one {hasil} other {hasil}}", "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Hapus", "status.delete": "Hapus",
"status.favourite": "Difavoritkan", "status.favourite": "Difavoritkan",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favorati:", "notifications.column_settings.favourite": "Favorati:",
"notifications.column_settings.follow": "Nova sequanti:", "notifications.column_settings.follow": "Nova sequanti:",
"notifications.column_settings.mention": "Mencioni:", "notifications.column_settings.mention": "Mencioni:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Repeti:", "notifications.column_settings.reblog": "Repeti:",
"notifications.column_settings.show": "Montrar en kolumno", "notifications.column_settings.show": "Montrar en kolumno",
"notifications.column_settings.sound": "Plear sono", "notifications.column_settings.sound": "Plear sono",
@ -147,6 +149,7 @@
"report.target": "Denuncante", "report.target": "Denuncante",
"search.placeholder": "Serchez", "search.placeholder": "Serchez",
"search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}", "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Efacar", "status.delete": "Efacar",
"status.favourite": "Favorizar", "status.favourite": "Favorizar",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Apprezzati:", "notifications.column_settings.favourite": "Apprezzati:",
"notifications.column_settings.follow": "Nuovi seguaci:", "notifications.column_settings.follow": "Nuovi seguaci:",
"notifications.column_settings.mention": "Menzioni:", "notifications.column_settings.mention": "Menzioni:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Post condivisi:", "notifications.column_settings.reblog": "Post condivisi:",
"notifications.column_settings.show": "Mostra in colonna", "notifications.column_settings.show": "Mostra in colonna",
"notifications.column_settings.sound": "Riproduci suono", "notifications.column_settings.sound": "Riproduci suono",
@ -147,6 +149,7 @@
"report.target": "Invio la segnalazione", "report.target": "Invio la segnalazione",
"search.placeholder": "Cerca", "search.placeholder": "Cerca",
"search_results.total": "{count} {count, plural, one {risultato} other {risultati}}", "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Elimina", "status.delete": "Elimina",
"status.favourite": "Apprezzato", "status.favourite": "Apprezzato",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "お気に入り", "notifications.column_settings.favourite": "お気に入り",
"notifications.column_settings.follow": "新しいフォロワー", "notifications.column_settings.follow": "新しいフォロワー",
"notifications.column_settings.mention": "返信", "notifications.column_settings.mention": "返信",
"notifications.column_settings.push": "プッシュ通知",
"notifications.column_settings.push_meta": "このデバイス",
"notifications.column_settings.reblog": "ブースト", "notifications.column_settings.reblog": "ブースト",
"notifications.column_settings.show": "カラムに表示", "notifications.column_settings.show": "カラムに表示",
"notifications.column_settings.sound": "通知音を再生", "notifications.column_settings.sound": "通知音を再生",
@ -147,6 +149,7 @@
"report.target": "問題のユーザー", "report.target": "問題のユーザー",
"search.placeholder": "検索", "search.placeholder": "検索",
"search_results.total": "{count, number}件の結果", "search_results.total": "{count, number}件の結果",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "この投稿はブーストできません", "status.cannot_reblog": "この投稿はブーストできません",
"status.delete": "削除", "status.delete": "削除",
"status.favourite": "お気に入り", "status.favourite": "お気に入り",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "즐겨찾기", "notifications.column_settings.favourite": "즐겨찾기",
"notifications.column_settings.follow": "새 팔로워", "notifications.column_settings.follow": "새 팔로워",
"notifications.column_settings.mention": "답글", "notifications.column_settings.mention": "답글",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "부스트", "notifications.column_settings.reblog": "부스트",
"notifications.column_settings.show": "컬럼에 표시", "notifications.column_settings.show": "컬럼에 표시",
"notifications.column_settings.sound": "효과음 재생", "notifications.column_settings.sound": "효과음 재생",
@ -147,6 +149,7 @@
"report.target": "문제가 된 사용자", "report.target": "문제가 된 사용자",
"search.placeholder": "검색", "search.placeholder": "검색",
"search_results.total": "{count, number}건의 결과", "search_results.total": "{count, number}건의 결과",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다", "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
"status.delete": "삭제", "status.delete": "삭제",
"status.favourite": "즐겨찾기", "status.favourite": "즐겨찾기",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favorieten:", "notifications.column_settings.favourite": "Favorieten:",
"notifications.column_settings.follow": "Nieuwe volgers:", "notifications.column_settings.follow": "Nieuwe volgers:",
"notifications.column_settings.mention": "Vermeldingen:", "notifications.column_settings.mention": "Vermeldingen:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "In kolom tonen", "notifications.column_settings.show": "In kolom tonen",
"notifications.column_settings.sound": "Geluid afspelen", "notifications.column_settings.sound": "Geluid afspelen",
@ -147,6 +149,7 @@
"report.target": "Rapporteren van", "report.target": "Rapporteren van",
"search.placeholder": "Zoeken", "search.placeholder": "Zoeken",
"search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}", "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Deze toot kan niet geboost worden", "status.cannot_reblog": "Deze toot kan niet geboost worden",
"status.delete": "Verwijderen", "status.delete": "Verwijderen",
"status.favourite": "Favoriet", "status.favourite": "Favoriet",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Likt:", "notifications.column_settings.favourite": "Likt:",
"notifications.column_settings.follow": "Nye følgere:", "notifications.column_settings.follow": "Nye følgere:",
"notifications.column_settings.mention": "Nevnt:", "notifications.column_settings.mention": "Nevnt:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Fremhevet:", "notifications.column_settings.reblog": "Fremhevet:",
"notifications.column_settings.show": "Vis i kolonne", "notifications.column_settings.show": "Vis i kolonne",
"notifications.column_settings.sound": "Spill lyd", "notifications.column_settings.sound": "Spill lyd",
@ -147,6 +149,7 @@
"report.target": "Rapporterer", "report.target": "Rapporterer",
"search.placeholder": "Søk", "search.placeholder": "Søk",
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Denne posten kan ikke fremheves", "status.cannot_reblog": "Denne posten kan ikke fremheves",
"status.delete": "Slett", "status.delete": "Slett",
"status.favourite": "Lik", "status.favourite": "Lik",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favorits :", "notifications.column_settings.favourite": "Favorits :",
"notifications.column_settings.follow": "Nòus seguidors :", "notifications.column_settings.follow": "Nòus seguidors :",
"notifications.column_settings.mention": "Mencions :", "notifications.column_settings.mention": "Mencions :",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Partatges :", "notifications.column_settings.reblog": "Partatges :",
"notifications.column_settings.show": "Mostrar dins la colomna", "notifications.column_settings.show": "Mostrar dins la colomna",
"notifications.column_settings.sound": "Emetre un son", "notifications.column_settings.sound": "Emetre un son",
@ -147,6 +149,7 @@
"report.target": "Senhalar {target}", "report.target": "Senhalar {target}",
"search.placeholder": "Recercar", "search.placeholder": "Recercar",
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat", "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
"status.delete": "Escafar", "status.delete": "Escafar",
"status.favourite": "Apondre als favorits", "status.favourite": "Apondre als favorits",

@ -3,10 +3,10 @@
"account.block_domain": "Blokuj wszystko z {domain}", "account.block_domain": "Blokuj wszystko z {domain}",
"account.disclaimer": "Ten użytkownik pochodzi z innej instancji. Ta liczba może być większa.", "account.disclaimer": "Ten użytkownik pochodzi z innej instancji. Ta liczba może być większa.",
"account.edit_profile": "Edytuj profil", "account.edit_profile": "Edytuj profil",
"account.follow": "Obserwuj", "account.follow": "Śledź",
"account.followers": "Obserwujący", "account.followers": "Śledzący",
"account.follows": "Obserwacje", "account.follows": "Śledzeni",
"account.follows_you": "Obserwuje cię", "account.follows_you": "Śledzi Cię",
"account.media": "Media", "account.media": "Media",
"account.mention": "Wspomnij o @{name}", "account.mention": "Wspomnij o @{name}",
"account.mute": "Wycisz @{name}", "account.mute": "Wycisz @{name}",
@ -15,7 +15,7 @@
"account.requested": "Oczekująca prośba", "account.requested": "Oczekująca prośba",
"account.unblock": "Odblokuj @{name}", "account.unblock": "Odblokuj @{name}",
"account.unblock_domain": "Odblokuj domenę {domain}", "account.unblock_domain": "Odblokuj domenę {domain}",
"account.unfollow": "Przestań obserwować", "account.unfollow": "Przestań śledzić",
"account.unmute": "Cofnij wyciszenie @{name}", "account.unmute": "Cofnij wyciszenie @{name}",
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
"bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.", "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
@ -27,7 +27,7 @@
"column.blocks": "Zablokowani użytkownicy", "column.blocks": "Zablokowani użytkownicy",
"column.community": "Lokalna oś czasu", "column.community": "Lokalna oś czasu",
"column.favourites": "Ulubione", "column.favourites": "Ulubione",
"column.follow_requests": "Prośby o obserwację", "column.follow_requests": "Prośby o śledzenie",
"column.home": "Strona główna", "column.home": "Strona główna",
"column.mutes": "Wyciszeni użytkownicy", "column.mutes": "Wyciszeni użytkownicy",
"column.notifications": "Powiadomienia", "column.notifications": "Powiadomienia",
@ -37,9 +37,9 @@
"column_header.unpin": "Cofnij przypięcie", "column_header.unpin": "Cofnij przypięcie",
"column_subheading.navigation": "Nawigacja", "column_subheading.navigation": "Nawigacja",
"column_subheading.settings": "Ustawienia", "column_subheading.settings": "Ustawienia",
"compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto cię obserwuje, może wyświetlać twoje posty przeznaczone tylko dla obserwujących.", "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje posty przeznaczone tylko dla śledzących.",
"compose_form.lock_disclaimer.lock": "zablokowane", "compose_form.lock_disclaimer.lock": "zablokowane",
"compose_form.placeholder": "Co ci chodzi po głowie?", "compose_form.placeholder": "Co Ci chodzi po głowie?",
"compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.", "compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.",
"compose_form.publish": "Wyślij", "compose_form.publish": "Wyślij",
"compose_form.publish_loud": "{publish}!", "compose_form.publish_loud": "{publish}!",
@ -67,7 +67,7 @@
"emoji_button.travel": "Podróże i miejsca", "emoji_button.travel": "Podróże i miejsca",
"empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!", "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
"empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!", "empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
"empty_column.home": "Nie obserwujesz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć ciekawych ludzi.", "empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
"empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.", "empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
"empty_column.home.public_timeline": "publiczna oś czasu", "empty_column.home.public_timeline": "publiczna oś czasu",
"empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.", "empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.",
@ -93,32 +93,34 @@
"navigation_bar.community_timeline": "Lokalna oś czasu", "navigation_bar.community_timeline": "Lokalna oś czasu",
"navigation_bar.edit_profile": "Edytuj profil", "navigation_bar.edit_profile": "Edytuj profil",
"navigation_bar.favourites": "Ulubione", "navigation_bar.favourites": "Ulubione",
"navigation_bar.follow_requests": "Prośby o obserwację", "navigation_bar.follow_requests": "Prośby o śledzenie",
"navigation_bar.info": "Szczegółowe informacje", "navigation_bar.info": "Szczegółowe informacje",
"navigation_bar.logout": "Wyloguj", "navigation_bar.logout": "Wyloguj",
"navigation_bar.mutes": "Wyciszeni użytkownicy", "navigation_bar.mutes": "Wyciszeni użytkownicy",
"navigation_bar.preferences": "Preferencje", "navigation_bar.preferences": "Preferencje",
"navigation_bar.public_timeline": "Oś czasu federacji", "navigation_bar.public_timeline": "Oś czasu federacji",
"notification.favourite": "{name} dodał twój status do ulubionych", "notification.favourite": "{name} dodał Twój status do ulubionych",
"notification.follow": "{name} zaczął cię obserwować", "notification.follow": "{name} zaczął Cię śledzić",
"notification.mention": "{name} wspomniał o tobie", "notification.mention": "{name} wspomniał o tobie",
"notification.reblog": "{name} podbił twój status", "notification.reblog": "{name} podbił Twój status",
"notifications.clear": "Wyczyść powiadomienia", "notifications.clear": "Wyczyść powiadomienia",
"notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?", "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
"notifications.column_settings.alert": "Powiadomienia na pulpicie", "notifications.column_settings.alert": "Powiadomienia na pulpicie",
"notifications.column_settings.favourite": "Ulubione:", "notifications.column_settings.favourite": "Ulubione:",
"notifications.column_settings.follow": "Nowi obserwujący:", "notifications.column_settings.follow": "Nowi śledzący:",
"notifications.column_settings.mention": "Wspomniali:", "notifications.column_settings.mention": "Wspomniali:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Podbili:", "notifications.column_settings.reblog": "Podbili:",
"notifications.column_settings.show": "Pokaż w kolumnie", "notifications.column_settings.show": "Pokaż w kolumnie",
"notifications.column_settings.sound": "Odtwarzaj dźwięk", "notifications.column_settings.sound": "Odtwarzaj dźwięk",
"onboarding.done": "Gotowe", "onboarding.done": "Gotowe",
"onboarding.next": "Dalej", "onboarding.next": "Dalej",
"onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy obserwowanych przez członków {domain}. Są to publiczne osie czasu najlepszy sposób na poznanie nowych osób.", "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy śledzonych przez członków {domain}. Są to publiczne osie czasu najlepszy sposób na poznanie nowych osób.",
"onboarding.page_four.home": "Główna oś czasu wyświetla publiczne wpisy.", "onboarding.page_four.home": "Główna oś czasu wyświetla publiczne wpisy.",
"onboarding.page_four.notifications": "Kolumna powiadomień wyświetla, gdy ktoś dokonuje interakcji z tobą.", "onboarding.page_four.notifications": "Kolumna powiadomień wyświetla, gdy ktoś dokonuje interakcji z tobą.",
"onboarding.page_one.federation": "Mastodon jest siecią niezależnych serwerów połączonych w jeden portal społecznościowy. Nazywamy te serwery instancjami.", "onboarding.page_one.federation": "Mastodon jest siecią niezależnych serwerów połączonych w jeden portal społecznościowy. Nazywamy te serwery instancjami.",
"onboarding.page_one.handle": "Jesteś na domenie {domain}, więc twój pełny adres to {handle}", "onboarding.page_one.handle": "Jesteś na domenie {domain}, więc Twój pełny adres to {handle}",
"onboarding.page_one.welcome": "Witamy w Mastodon!", "onboarding.page_one.welcome": "Witamy w Mastodon!",
"onboarding.page_six.admin": "Administratorem tej instancji jest {admin}.", "onboarding.page_six.admin": "Administratorem tej instancji jest {admin}.",
"onboarding.page_six.almost_done": "Prawie gotowe...", "onboarding.page_six.almost_done": "Prawie gotowe...",
@ -135,8 +137,8 @@
"privacy.change": "Dostosuj widoczność postów", "privacy.change": "Dostosuj widoczność postów",
"privacy.direct.long": "Widoczne tylko dla oznaczonych", "privacy.direct.long": "Widoczne tylko dla oznaczonych",
"privacy.direct.short": "Bezpośrednio", "privacy.direct.short": "Bezpośrednio",
"privacy.private.long": "Widoczne tylko dla obserwujących", "privacy.private.long": "Widoczne tylko dla śledzących",
"privacy.private.short": "Tylko obserwujący", "privacy.private.short": "Tylko śledzący",
"privacy.public.long": "Widoczne na publicznych osiach czasu", "privacy.public.long": "Widoczne na publicznych osiach czasu",
"privacy.public.short": "Publiczne", "privacy.public.short": "Publiczne",
"privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu", "privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
@ -147,6 +149,7 @@
"report.target": "Zgłaszanie {target}", "report.target": "Zgłaszanie {target}",
"search.placeholder": "Szukaj", "search.placeholder": "Szukaj",
"search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}", "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Ten post nie może zostać podbity", "status.cannot_reblog": "Ten post nie może zostać podbity",
"status.delete": "Usuń", "status.delete": "Usuń",
"status.favourite": "Ulubione", "status.favourite": "Ulubione",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favoritos:", "notifications.column_settings.favourite": "Favoritos:",
"notifications.column_settings.follow": "Novos seguidores:", "notifications.column_settings.follow": "Novos seguidores:",
"notifications.column_settings.mention": "Menções:", "notifications.column_settings.mention": "Menções:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Partilhas:", "notifications.column_settings.reblog": "Partilhas:",
"notifications.column_settings.show": "Mostrar nas colunas", "notifications.column_settings.show": "Mostrar nas colunas",
"notifications.column_settings.sound": "Reproduzir som", "notifications.column_settings.sound": "Reproduzir som",
@ -147,6 +149,7 @@
"report.target": "Denunciar", "report.target": "Denunciar",
"search.placeholder": "Pesquisar", "search.placeholder": "Pesquisar",
"search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Eliminar", "status.delete": "Eliminar",
"status.favourite": "Adicionar aos favoritos", "status.favourite": "Adicionar aos favoritos",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favoritos:", "notifications.column_settings.favourite": "Favoritos:",
"notifications.column_settings.follow": "Novos seguidores:", "notifications.column_settings.follow": "Novos seguidores:",
"notifications.column_settings.mention": "Menções:", "notifications.column_settings.mention": "Menções:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Partilhas:", "notifications.column_settings.reblog": "Partilhas:",
"notifications.column_settings.show": "Mostrar nas colunas", "notifications.column_settings.show": "Mostrar nas colunas",
"notifications.column_settings.sound": "Reproduzir som", "notifications.column_settings.sound": "Reproduzir som",
@ -147,6 +149,7 @@
"report.target": "Denunciar", "report.target": "Denunciar",
"search.placeholder": "Pesquisar", "search.placeholder": "Pesquisar",
"search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Eliminar", "status.delete": "Eliminar",
"status.favourite": "Adicionar aos favoritos", "status.favourite": "Adicionar aos favoritos",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Нравится:", "notifications.column_settings.favourite": "Нравится:",
"notifications.column_settings.follow": "Новые подписчики:", "notifications.column_settings.follow": "Новые подписчики:",
"notifications.column_settings.mention": "Упоминания:", "notifications.column_settings.mention": "Упоминания:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Продвижения:", "notifications.column_settings.reblog": "Продвижения:",
"notifications.column_settings.show": "Показывать в колонке", "notifications.column_settings.show": "Показывать в колонке",
"notifications.column_settings.sound": "Проигрывать звук", "notifications.column_settings.sound": "Проигрывать звук",
@ -147,6 +149,7 @@
"report.target": "Жалуемся на", "report.target": "Жалуемся на",
"search.placeholder": "Поиск", "search.placeholder": "Поиск",
"search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Этот статус не может быть продвинут", "status.cannot_reblog": "Этот статус не может быть продвинут",
"status.delete": "Удалить", "status.delete": "Удалить",
"status.favourite": "Нравится", "status.favourite": "Нравится",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favourites:", "notifications.column_settings.favourite": "Favourites:",
"notifications.column_settings.follow": "New followers:", "notifications.column_settings.follow": "New followers:",
"notifications.column_settings.mention": "Mentions:", "notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Show in column", "notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
@ -147,6 +149,7 @@
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "Search", "search.placeholder": "Search",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Delete", "status.delete": "Delete",
"status.favourite": "Favourite", "status.favourite": "Favourite",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Favoriler:", "notifications.column_settings.favourite": "Favoriler:",
"notifications.column_settings.follow": "Yeni takipçiler:", "notifications.column_settings.follow": "Yeni takipçiler:",
"notifications.column_settings.mention": "Bahsedilenler:", "notifications.column_settings.mention": "Bahsedilenler:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Boostlar:", "notifications.column_settings.reblog": "Boostlar:",
"notifications.column_settings.show": "Bildirimlerde göster", "notifications.column_settings.show": "Bildirimlerde göster",
"notifications.column_settings.sound": "Ses çal", "notifications.column_settings.sound": "Ses çal",
@ -147,6 +149,7 @@
"report.target": "Raporlama", "report.target": "Raporlama",
"search.placeholder": "Ara", "search.placeholder": "Ara",
"search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}", "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Bu gönderi boost edilemez", "status.cannot_reblog": "Bu gönderi boost edilemez",
"status.delete": "Sil", "status.delete": "Sil",
"status.favourite": "Favorilere ekle", "status.favourite": "Favorilere ekle",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "Вподобане:", "notifications.column_settings.favourite": "Вподобане:",
"notifications.column_settings.follow": "Нові підписники:", "notifications.column_settings.follow": "Нові підписники:",
"notifications.column_settings.mention": "Сповіщення:", "notifications.column_settings.mention": "Сповіщення:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "Передмухи:", "notifications.column_settings.reblog": "Передмухи:",
"notifications.column_settings.show": "Показати в колонці", "notifications.column_settings.show": "Показати в колонці",
"notifications.column_settings.sound": "Відтворювати звук", "notifications.column_settings.sound": "Відтворювати звук",
@ -147,6 +149,7 @@
"report.target": "Скаржимося на", "report.target": "Скаржимося на",
"search.placeholder": "Пошук", "search.placeholder": "Пошук",
"search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}", "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Цей допис не може бути передмухнутий", "status.cannot_reblog": "Цей допис не може бути передмухнутий",
"status.delete": "Видалити", "status.delete": "Видалити",
"status.favourite": "Подобається", "status.favourite": "Подобається",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "你的嘟文被赞:", "notifications.column_settings.favourite": "你的嘟文被赞:",
"notifications.column_settings.follow": "关注你:", "notifications.column_settings.follow": "关注你:",
"notifications.column_settings.mention": "提及你:", "notifications.column_settings.mention": "提及你:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "你的嘟文被转嘟:", "notifications.column_settings.reblog": "你的嘟文被转嘟:",
"notifications.column_settings.show": "在通知栏显示", "notifications.column_settings.show": "在通知栏显示",
"notifications.column_settings.sound": "播放音效", "notifications.column_settings.sound": "播放音效",
@ -147,6 +149,7 @@
"report.target": "Reporting", "report.target": "Reporting",
"search.placeholder": "搜索", "search.placeholder": "搜索",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}", "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "没法转嘟这条嘟文啦……", "status.cannot_reblog": "没法转嘟这条嘟文啦……",
"status.delete": "删除", "status.delete": "删除",
"status.favourite": "赞", "status.favourite": "赞",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "喜歡你的文章:", "notifications.column_settings.favourite": "喜歡你的文章:",
"notifications.column_settings.follow": "關注你:", "notifications.column_settings.follow": "關注你:",
"notifications.column_settings.mention": "提及你:", "notifications.column_settings.mention": "提及你:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "轉推你的文章:", "notifications.column_settings.reblog": "轉推你的文章:",
"notifications.column_settings.show": "在通知欄顯示", "notifications.column_settings.show": "在通知欄顯示",
"notifications.column_settings.sound": "播放音效", "notifications.column_settings.sound": "播放音效",
@ -147,6 +149,7 @@
"report.target": "舉報", "report.target": "舉報",
"search.placeholder": "搜尋", "search.placeholder": "搜尋",
"search_results.total": "{count, number} 項結果", "search_results.total": "{count, number} 項結果",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "這篇文章無法被轉推", "status.cannot_reblog": "這篇文章無法被轉推",
"status.delete": "刪除", "status.delete": "刪除",
"status.favourite": "喜歡", "status.favourite": "喜歡",

@ -109,6 +109,8 @@
"notifications.column_settings.favourite": "最愛:", "notifications.column_settings.favourite": "最愛:",
"notifications.column_settings.follow": "新的關注者:", "notifications.column_settings.follow": "新的關注者:",
"notifications.column_settings.mention": "提到:", "notifications.column_settings.mention": "提到:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
"notifications.column_settings.reblog": "轉推:", "notifications.column_settings.reblog": "轉推:",
"notifications.column_settings.show": "顯示在欄位中", "notifications.column_settings.show": "顯示在欄位中",
"notifications.column_settings.sound": "播放音效", "notifications.column_settings.sound": "播放音效",
@ -147,6 +149,7 @@
"report.target": "通報中", "report.target": "通報中",
"search.placeholder": "搜尋", "search.placeholder": "搜尋",
"search_results.total": "{count, number} 項結果", "search_results.total": "{count, number} 項結果",
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "此貼文無法轉推", "status.cannot_reblog": "此貼文無法轉推",
"status.delete": "刪除", "status.delete": "刪除",
"status.favourite": "喜愛", "status.favourite": "喜愛",

@ -1,12 +1,6 @@
const perf = require('./performance'); import ready from './ready';
function onDomContentLoaded(callback) { const perf = require('./performance');
if (document.readyState !== 'loading') {
callback();
} else {
document.addEventListener('DOMContentLoaded', callback);
}
}
function main() { function main() {
perf.start('main()'); perf.start('main()');
@ -24,11 +18,19 @@ function main() {
} }
} }
onDomContentLoaded(() => { ready(() => {
const mountNode = document.getElementById('mastodon'); const mountNode = document.getElementById('mastodon');
const props = JSON.parse(mountNode.getAttribute('data-props')); const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(<Mastodon {...props} />, mountNode); ReactDOM.render(<Mastodon {...props} />, mountNode);
if (process.env.NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug
const OfflinePluginRuntime = require('offline-plugin/runtime');
const WebPushSubscription = require('./web_push_subscription');
OfflinePluginRuntime.install();
WebPushSubscription.register();
}
perf.stop('main()'); perf.stop('main()');
// remember the initial URL // remember the initial URL

@ -0,0 +1,7 @@
export default function ready(loaded) {
if (['interactive', 'complete'].includes(document.readyState)) {
loaded();
} else {
document.addEventListener('DOMContentLoaded', loaded);
}
}

@ -126,7 +126,7 @@ const insertSuggestion = (state, position, token, completion) => {
}; };
const insertEmoji = (state, position, emojiData) => { const insertEmoji = (state, position, emojiData) => {
const emoji = emojiData.shortname; const emoji = String.fromCodePoint(parseInt(emojiData.unicode, 16));
return state.withMutations(map => { return state.withMutations(map => {
map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`); map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);

@ -11,6 +11,7 @@ import statuses from './statuses';
import relationships from './relationships'; import relationships from './relationships';
import settings from './settings'; import settings from './settings';
import local_settings from '../../glitch/reducers/local_settings'; import local_settings from '../../glitch/reducers/local_settings';
import push_notifications from './push_notifications';
import status_lists from './status_lists'; import status_lists from './status_lists';
import cards from './cards'; import cards from './cards';
import reports from './reports'; import reports from './reports';
@ -33,7 +34,11 @@ const reducers = {
statuses, statuses,
relationships, relationships,
settings, settings,
<<<<<<< HEAD
local_settings, local_settings,
=======
push_notifications,
>>>>>>> upstream
cards, cards,
reports, reports,
contexts, contexts,

@ -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;
}
};

@ -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);

@ -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.');
}
}

@ -0,0 +1,24 @@
import TimelineContainer from '../mastodon/containers/timeline_container';
import React from 'react';
import ReactDOM from 'react-dom';
import loadPolyfills from '../mastodon/load_polyfills';
import ready from '../mastodon/ready';
require.context('../images/', true);
function loaded() {
const mountNode = document.getElementById('mastodon-timeline');
if (mountNode !== null) {
const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(<TimelineContainer {...props} />, mountNode);
}
}
function main() {
ready(loaded);
}
loadPolyfills().then(main).catch(error => {
console.error(error);
});

@ -5,9 +5,7 @@ import emojify from '../mastodon/emoji';
import { getLocale } from '../mastodon/locales'; import { getLocale } from '../mastodon/locales';
import loadPolyfills from '../mastodon/load_polyfills'; import loadPolyfills from '../mastodon/load_polyfills';
import { processBio } from '../glitch/util/bio_metadata'; import { processBio } from '../glitch/util/bio_metadata';
import TimelineContainer from '../mastodon/containers/timeline_container'; import ready from '../mastodon/ready';
import React from 'react';
import ReactDOM from 'react-dom';
require.context('../images/', true); require.context('../images/', true);
@ -40,21 +38,10 @@ function loaded() {
const datetime = new Date(content.getAttribute('datetime')); const datetime = new Date(content.getAttribute('datetime'));
content.textContent = relativeFormat.format(datetime);; content.textContent = relativeFormat.format(datetime);;
}); });
const mountNode = document.getElementById('mastodon-timeline');
if (mountNode !== null) {
const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(<TimelineContainer {...props} />, mountNode);
}
} }
function main() { function main() {
if (['interactive', 'complete'].includes(document.readyState)) { ready(loaded);
loaded();
} else {
document.addEventListener('DOMContentLoaded', loaded);
}
delegate(document, '.video-player video', 'click', ({ target }) => { delegate(document, '.video-player video', 'click', ({ target }) => {
if (target.paused) { if (target.paused) {

@ -1554,6 +1554,9 @@
} }
.react-swipeable-view-container > * { .react-swipeable-view-container > * {
display: flex;
align-items: center;
justify-content: center;
height: 100%; height: 100%;
} }
@ -2007,6 +2010,7 @@
width: 100%; width: 100%;
margin: 0; margin: 0;
color: $ui-base-color; color: $ui-base-color;
background: $simple-background-color;
padding: 10px; padding: 10px;
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
@ -2029,7 +2033,6 @@
.autosuggest-textarea__textarea { .autosuggest-textarea__textarea {
min-height: 100px; min-height: 100px;
background: $simple-background-color;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
padding-bottom: 0; padding-bottom: 0;
padding-right: 10px + 22px; padding-right: 10px + 22px;
@ -2620,7 +2623,8 @@ button.icon-button.active i.fa-retweet {
line-height: 24px; line-height: 24px;
} }
.setting-toggle__label { .setting-toggle__label,
.setting-meta__label {
color: $ui-primary-color; color: $ui-primary-color;
display: inline-block; display: inline-block;
margin-bottom: 14px; margin-bottom: 14px;
@ -2628,6 +2632,11 @@ button.icon-button.active i.fa-retweet {
vertical-align: middle; vertical-align: middle;
} }
.setting-meta__label {
color: $ui-primary-color;
float: right;
}
.empty-column-indicator, .empty-column-indicator,
.error-column { .error-column {
color: lighten($ui-base-color, 20%); color: lighten($ui-base-color, 20%);
@ -2968,6 +2977,7 @@ button.icon-button.active i.fa-retweet {
margin-left: 2px; margin-left: 2px;
width: 24px; width: 24px;
outline: 0; outline: 0;
cursor: pointer;
&:active, &:active,
&:focus { &:focus {
@ -3297,6 +3307,7 @@ button.icon-button.active i.fa-retweet {
max-height: 80vh; max-height: 80vh;
position: relative; position: relative;
.extended-video-player,
img, img,
canvas, canvas,
video { video {
@ -3306,6 +3317,13 @@ button.icon-button.active i.fa-retweet {
height: auto; height: auto;
} }
.extended-video-player,
video {
display: flex;
width: 80vw;
height: 80vh;
}
img, img,
canvas { canvas {
display: block; display: block;

@ -45,6 +45,10 @@ body.rtl {
margin-right: 8px; margin-right: 8px;
} }
.setting-meta__label {
float: left;
}
.status__avatar { .status__avatar {
left: auto; left: auto;
right: 10px; right: 10px;

@ -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

@ -0,0 +1,69 @@
# frozen_string_literal: true
require 'singleton'
class ActivityPub::TagManager
include Singleton
include RoutingHelper
COLLECTIONS = {
public: 'https://www.w3.org/ns/activitystreams#Public',
}.freeze
def url_for(target)
return target.url if target.respond_to?(:local?) && !target.local?
case target.object_type
when :person
short_account_url(target)
when :note, :comment, :activity
short_account_status_url(target.account, target)
end
end
def uri_for(target)
return target.uri if target.respond_to?(:local?) && !target.local?
case target.object_type
when :person
account_url(target)
when :note, :comment, :activity
account_status_url(target.account, target)
end
end
# Primary audience of a status
# Public statuses go out to primarily the public collection
# Unlisted and private statuses go out primarily to the followers collection
# Others go out only to the people they mention
def to(status)
case status.visibility
when 'public'
[COLLECTIONS[:public]]
when 'unlisted', 'private'
[account_followers_url(status.account)]
when 'direct'
status.mentions.map { |mention| uri_for(mention.account) }
end
end
# Secondary audience of a status
# Public statuses go out to followers as well
# Unlisted statuses go to the public as well
# Both of those and private statuses also go to the people mentioned in them
# Direct ones don't have a secondary audience
def cc(status)
cc = []
case status.visibility
when 'public'
cc << account_followers_url(status.account)
when 'unlisted'
cc << COLLECTIONS[:public]
end
cc.concat(status.mentions.map { |mention| uri_for(mention.account) }) unless status.direct_visibility?
cc
end
end

@ -99,7 +99,7 @@ class FeedManager
#return true if reggie === status.content || reggie === status.spoiler_text #return true if reggie === status.content || reggie === status.spoiler_text
# extremely violent filtering code END # extremely violent filtering code END
return true if status.reply? && status.in_reply_to_id.nil? return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
check_for_mutes = [status.account_id] check_for_mutes = [status.account_id]
check_for_mutes.concat([status.reblog.account_id]) if status.reblog? check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
@ -126,12 +126,13 @@ class FeedManager
end end
def filter_from_mentions?(status, receiver_id) def filter_from_mentions?(status, receiver_id)
return true if receiver_id == status.account_id
check_for_blocks = [status.account_id] check_for_blocks = [status.account_id]
check_for_blocks.concat(status.mentions.pluck(:account_id)) check_for_blocks.concat(status.mentions.pluck(:account_id))
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil? check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
should_filter = receiver_id == status.account_id # Filter if I'm mentioning myself should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
should_filter ||= Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # or it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
should_filter should_filter

@ -1,11 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class ProviderDiscovery < OEmbed::ProviderDiscovery class ProviderDiscovery < OEmbed::ProviderDiscovery
extend HttpHelper
class << self class << self
def discover_provider(url, options = {}) def discover_provider(url, options = {})
res = http_client.get(url) res = Request.new(:get, url).perform
format = options[:format] format = options[:format]
raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html' raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'

@ -0,0 +1,70 @@
# frozen_string_literal: true
class Request
REQUEST_TARGET = '(request-target)'
include RoutingHelper
def initialize(verb, url, options = {})
@verb = verb
@url = Addressable::URI.parse(url).normalize
@options = options
@headers = {}
set_common_headers!
end
def on_behalf_of(account)
raise ArgumentError unless account.local?
@account = account
end
def add_headers(new_headers)
@headers.merge!(new_headers)
end
def perform
http_client.headers(headers).public_send(@verb, @url.to_s, @options)
end
def headers
(@account ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET)
end
private
def set_common_headers!
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
@headers['User-Agent'] = user_agent
@headers['Host'] = @url.host
@headers['Date'] = Time.now.utc.httpdate
end
def signature
key_id = @account.to_webfinger_s
algorithm = 'rsa-sha256'
signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers}\",signature=\"#{signature}\""
end
def signed_string
@headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
end
def signed_headers
@headers.keys.join(' ').downcase
end
def user_agent
@user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
end
def timeout
{ write: 10, connect: 10, read: 10 }
end
def http_client
HTTP.timeout(:per_operation, timeout).follow
end
end

@ -70,7 +70,7 @@ class TagManager
uri = Addressable::URI.new uri = Addressable::URI.new
uri.host = domain.gsub(/[\/]/, '') uri.host = domain.gsub(/[\/]/, '')
uri.normalize.host uri.normalized_host
end end
def same_acct?(canonical, needle) def same_acct?(canonical, needle)

@ -23,6 +23,7 @@ class UserSettingsDecorator
user.settings['delete_modal'] = delete_modal_preference user.settings['delete_modal'] = delete_modal_preference
user.settings['auto_play_gif'] = auto_play_gif_preference user.settings['auto_play_gif'] = auto_play_gif_preference
user.settings['system_font_ui'] = system_font_ui_preference user.settings['system_font_ui'] = system_font_ui_preference
user.settings['noindex'] = noindex_preference
end end
def merged_notification_emails def merged_notification_emails
@ -57,6 +58,10 @@ class UserSettingsDecorator
boolean_cast_setting 'setting_auto_play_gif' boolean_cast_setting 'setting_auto_play_gif'
end end
def noindex_preference
boolean_cast_setting 'setting_noindex'
end
def boolean_cast_setting(key) def boolean_cast_setting(key)
settings[key] == '1' settings[key] == '1'
end end

@ -47,6 +47,7 @@ class Account < ApplicationRecord
include AccountInteractions include AccountInteractions
include Attachmentable include Attachmentable
include Remotable include Remotable
include EmojiHelper
# Local users # Local users
has_one :user, inverse_of: :account has_one :user, inverse_of: :account
@ -129,7 +130,7 @@ class Account < ApplicationRecord
end end
def subscription(webhook_url) def subscription(webhook_url)
OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 86_400 * 30, webhook: webhook_url, hub: hub_url) OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
end end
def save_with_optional_media! def save_with_optional_media!
@ -240,9 +241,18 @@ class Account < ApplicationRecord
before_create :generate_keys before_create :generate_keys
before_validation :normalize_domain before_validation :normalize_domain
before_validation :prepare_contents, if: :local?
private private
def prepare_contents
display_name&.strip!
note&.strip!
self.display_name = emojify(display_name)
self.note = emojify(note)
end
def generate_keys def generate_keys
return unless local? return unless local?

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Remotable module Remotable
include HttpHelper
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
@ -20,7 +19,7 @@ module Remotable
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url
begin begin
response = http_client.get(url) response = Request.new(:get, url).perform
return if response.code != 200 return if response.code != 200

@ -8,7 +8,7 @@
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# severity :integer default("silence") # severity :integer default("silence")
# reject_media :boolean # reject_media :boolean default(FALSE), not null
# #
class DomainBlock < ApplicationRecord class DomainBlock < ApplicationRecord

@ -6,7 +6,7 @@
# id :integer not null, primary key # id :integer not null, primary key
# account_id :integer not null # account_id :integer not null
# type :integer not null # type :integer not null
# approved :boolean # approved :boolean default(FALSE), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# data_file_name :string # data_file_name :string

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

Loading…
Cancel
Save