Compare commits
117 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
8963f8c3c2 | ||
|
5e41c26203 | ||
|
45837c533e | ||
|
3fa8512474 | ||
|
0e20de9f89 | ||
|
24d645b7d0 | ||
|
7b23f79d41 | ||
|
3b4095cf1b | ||
|
28cbfb9f10 | ||
|
189a06d2a2 | ||
|
450441fc11 | ||
|
b619362a36 | ||
|
425d02287a | ||
|
2e429c0c25 | ||
|
e0e12b0fee | ||
|
62ca37884a | ||
|
f9180823bc | ||
|
4b0c667c09 | ||
|
1b732cad61 | ||
|
ecef03bb15 | ||
|
9642601126 | ||
|
3836d293a1 | ||
|
0734e1fe33 | ||
|
44cb08297c | ||
|
bd21afb5ed | ||
|
ef80ad17b3 | ||
|
9ea4f37e78 | ||
|
c48772fd3f | ||
|
860e257a68 | ||
|
902d9e34b4 | ||
|
2d97c898f2 | ||
|
f6a93fc150 | ||
|
019f3377bb | ||
|
4b11675bdc | ||
|
2531c5953b | ||
|
c6db416ff7 | ||
|
b00cb2aed3 | ||
|
7c67cb5997 | ||
|
a098d08d12 | ||
|
6267759607 | ||
|
bc39ad37c4 | ||
|
a6ba004bf5 | ||
|
b89ab7e69d | ||
|
33d7338779 | ||
|
cf4fe6cab8 | ||
|
2241a15ee9 | ||
|
bca334cd28 | ||
|
3e3ec9b2c8 | ||
|
a8736aab7a | ||
|
71b266377c | ||
|
08dce5e607 | ||
|
2469fd1cdc | ||
|
531c1bb245 | ||
|
58f5040ee8 | ||
|
838f51770b | ||
|
c52090dbfe | ||
|
807c192fcf | ||
|
3b59f9c6c2 | ||
|
135bdd149e | ||
|
3572138b16 | ||
|
9f69aa3cb1 | ||
|
f5c3d20e9c | ||
|
1ec7c87001 | ||
|
8e4d1cba00 | ||
|
676ba50601 | ||
|
bbc3db8b20 | ||
|
f937cad68f | ||
|
be83d450eb | ||
|
1fd18a61bd | ||
|
5d9f479538 | ||
|
3ce9ca4c99 | ||
|
2ca1f0737a | ||
|
19ecde8fe7 | ||
|
7ee5fc5d68 | ||
|
4289ed1d13 | ||
|
256e3adc1d | ||
|
152b4d54e8 | ||
|
ea2ef16ea4 | ||
|
1d3e0a5060 | ||
|
bf575a1f5e | ||
|
860ffc0560 | ||
|
7eb4abe20a | ||
|
1baa75f79f | ||
|
1d436a4322 | ||
|
8fd174298d | ||
|
9afd7dadbf | ||
|
8e84177305 | ||
|
a28ce13b3e | ||
|
e1b42e9aa0 | ||
|
b51398d0dd | ||
|
ec34ec63b1 | ||
|
4a4733b397 | ||
|
bda7391221 | ||
|
b9e8ffbd12 | ||
|
7966d3a872 | ||
|
422e4d897b | ||
|
cb2707776f | ||
|
48e7a22e34 | ||
|
2bb5486357 | ||
|
60e2b951de | ||
|
a94c152fd3 | ||
|
9d04de1c8d | ||
|
73e4468ff3 | ||
|
fbbd80b40b | ||
|
361a606edb | ||
|
df92f010ad | ||
|
07af8c05fd | ||
|
aa662cecad | ||
|
84608c3ff8 | ||
|
b69365e397 | ||
|
a478af92c3 | ||
|
7fba4cb3d1 | ||
|
a4c757767f | ||
|
2af5cd96fe | ||
|
860f408475 | ||
|
440441ccb3 | ||
|
3eb13307ca |
109
.env.nanobox
Normal file
109
.env.nanobox
Normal file
@@ -0,0 +1,109 @@
|
||||
# Service dependencies
|
||||
# You may set REDIS_URL instead for more advanced options
|
||||
REDIS_HOST=$DATA_REDIS_HOST
|
||||
REDIS_PORT=6379
|
||||
# REDIS_DB=0
|
||||
|
||||
# You may set DATABASE_URL instead for more advanced options
|
||||
DB_HOST=$DATA_DB_HOST
|
||||
DB_USER=$DATA_DB_USER
|
||||
DB_NAME=gonano
|
||||
DB_PASS=$DATA_DB_PASS
|
||||
DB_PORT=5432
|
||||
|
||||
# Federation
|
||||
# Note: Changing LOCAL_DOMAIN or LOCAL_HTTPS at a later time will cause unwanted side effects.
|
||||
# LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com.
|
||||
LOCAL_DOMAIN=${APP_NAME}.nanoapp.io
|
||||
LOCAL_HTTPS=false
|
||||
|
||||
# Use this only if you need to run mastodon on a different domain than the one used for federation.
|
||||
# You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md
|
||||
# DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING.
|
||||
# WEB_DOMAIN=mastodon.example.com
|
||||
|
||||
# Use this if you want to have several aliases handler@example1.com
|
||||
# handler@example2.com etc. for the same user. LOCAL_DOMAIN should not
|
||||
# be added. Comma separated values
|
||||
# ALTERNATE_DOMAINS=example1.com,example2.com
|
||||
|
||||
# Application secrets
|
||||
# Generate each with the `rake secret` task (`nanobox run bundle exec rake secret`)
|
||||
PAPERCLIP_SECRET=$PAPERCLIP_SECRET
|
||||
SECRET_KEY_BASE=$SECRET_KEY_BASE
|
||||
OTP_SECRET=$OTP_SECRET
|
||||
|
||||
# Registrations
|
||||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
||||
# SINGLE_USER_MODE=true
|
||||
# Prevent registrations with following e-mail domains
|
||||
# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc
|
||||
# Only allow registrations with the following e-mail domains
|
||||
# EMAIL_DOMAIN_WHITELIST=example1.com|example2.de|etc
|
||||
|
||||
# Optionally change default language
|
||||
# DEFAULT_LOCALE=de
|
||||
|
||||
# E-mail configuration
|
||||
# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
|
||||
# If you want to use an SMTP server without authentication (e.g local Postfix relay)
|
||||
# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and
|
||||
# *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough).
|
||||
SMTP_SERVER=$SMTP_SERVER
|
||||
SMTP_PORT=587
|
||||
SMTP_LOGIN=$SMTP_LOGIN
|
||||
SMTP_PASSWORD=$SMTP_PASSWORD
|
||||
SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
|
||||
#SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
|
||||
#SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
|
||||
#SMTP_AUTH_METHOD=plain
|
||||
#SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
#SMTP_OPENSSL_VERIFY_MODE=peer
|
||||
#SMTP_ENABLE_STARTTLS_AUTO=true
|
||||
|
||||
|
||||
# Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files.
|
||||
# PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system
|
||||
# PAPERCLIP_ROOT_URL=/system
|
||||
|
||||
# Optional asset host for multi-server setups
|
||||
# CDN_HOST=assets.example.com
|
||||
|
||||
# S3 (optional)
|
||||
# S3_ENABLED=true
|
||||
# S3_BUCKET=
|
||||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
# S3_REGION=
|
||||
# S3_PROTOCOL=http
|
||||
# S3_HOSTNAME=192.168.1.123:9000
|
||||
|
||||
# S3 (Minio Config (optional) Please check Minio instance for details)
|
||||
# S3_ENABLED=true
|
||||
# S3_BUCKET=
|
||||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
# S3_REGION=
|
||||
# S3_PROTOCOL=https
|
||||
# S3_HOSTNAME=
|
||||
# S3_ENDPOINT=
|
||||
# S3_SIGNATURE_VERSION=
|
||||
|
||||
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
|
||||
# S3_CLOUDFRONT_HOST=
|
||||
|
||||
# Streaming API integration
|
||||
# STREAMING_API_BASE_URL=
|
||||
|
||||
# Advanced settings
|
||||
# If you need to use pgBouncer, you need to disable prepared statements:
|
||||
# PREPARED_STATEMENTS=false
|
||||
|
||||
# Cluster number setting for streaming API server.
|
||||
# If you comment out following line, cluster number will be `numOfCpuCores - 1`.
|
||||
STREAMING_CLUSTER_NUM=1
|
||||
|
||||
# Docker mastodon user
|
||||
# If you use Docker, you may want to assign UID/GID manually.
|
||||
# UID=1000
|
||||
# GID=1000
|
@@ -20,6 +20,11 @@ LOCAL_HTTPS=true
|
||||
# DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING.
|
||||
# WEB_DOMAIN=mastodon.example.com
|
||||
|
||||
# Use this if you want to have several aliases handler@example1.com
|
||||
# handler@example2.com etc. for the same user. LOCAL_DOMAIN should not
|
||||
# be added. Comma separated values
|
||||
# ALTERNATE_DOMAINS=example1.com,example2.com
|
||||
|
||||
# Application secrets
|
||||
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
|
||||
PAPERCLIP_SECRET=
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -19,6 +19,7 @@
|
||||
coverage
|
||||
public/system
|
||||
public/assets
|
||||
public/packs
|
||||
.env
|
||||
.env.production
|
||||
node_modules/
|
||||
@@ -43,9 +44,6 @@ redis
|
||||
# Ignore vim files
|
||||
*~
|
||||
*.swp
|
||||
/public/packs
|
||||
/node_modules
|
||||
|
||||
|
||||
# Ignore npm debug log
|
||||
npm-debug.log
|
||||
|
20
.nanoignore
Normal file
20
.nanoignore
Normal file
@@ -0,0 +1,20 @@
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
.bundle/
|
||||
.cache/
|
||||
config/deploy/*
|
||||
coverage
|
||||
docs/
|
||||
.env
|
||||
log/*.log
|
||||
neo4j/
|
||||
node_modules/
|
||||
public/assets/
|
||||
public/system/
|
||||
spec/
|
||||
storybook/
|
||||
tmp/
|
||||
.vagrant/
|
||||
vendor/bundle/
|
1
.profile
Normal file
1
.profile
Normal file
@@ -0,0 +1 @@
|
||||
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio
|
@@ -74,6 +74,9 @@ Style/RegexpLiteral:
|
||||
Style/Lambda:
|
||||
Enabled: false
|
||||
|
||||
Style/GuardClause:
|
||||
Enabled: false
|
||||
|
||||
Rails/HasAndBelongsToMany:
|
||||
Enabled: false
|
||||
|
||||
|
2
Gemfile
2
Gemfile
@@ -19,7 +19,7 @@ gem 'paperclip', '~> 5.1'
|
||||
gem 'paperclip-av-transcoder', '~> 0.6'
|
||||
|
||||
gem 'addressable', '~> 2.5'
|
||||
gem 'bootsnap'
|
||||
gem 'bootsnap', '~> 0.3'
|
||||
gem 'cld3', '~> 3.1'
|
||||
gem 'devise', '~> 4.2'
|
||||
gem 'devise-two-factor', '~> 3.0'
|
||||
|
@@ -67,7 +67,7 @@ GEM
|
||||
rack (>= 0.9.0)
|
||||
binding_of_caller (0.7.2)
|
||||
debug_inspector (>= 0.0.1)
|
||||
bootsnap (0.2.14)
|
||||
bootsnap (0.3.0)
|
||||
msgpack (~> 1.0)
|
||||
brakeman (3.6.1)
|
||||
builder (3.2.3)
|
||||
@@ -474,7 +474,7 @@ DEPENDENCIES
|
||||
aws-sdk (~> 2.9)
|
||||
better_errors (~> 2.1)
|
||||
binding_of_caller (~> 0.7)
|
||||
bootsnap
|
||||
bootsnap (~> 0.3)
|
||||
brakeman (~> 3.6)
|
||||
bullet (~> 5.5)
|
||||
bundler-audit (~> 0.5)
|
||||
|
@@ -6,12 +6,12 @@ class AccountsController < ApplicationController
|
||||
def show
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@statuses = @account.statuses.permitted_for(@account, current_account).order(id: :desc).paginate_by_max_id(20, params[:max_id], params[:since_id])
|
||||
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
end
|
||||
|
||||
format.atom do
|
||||
@entries = @account.stream_entries.order(id: :desc).where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
|
||||
@entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
|
||||
render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
|
||||
end
|
||||
|
||||
|
@@ -2,17 +2,31 @@
|
||||
|
||||
module Admin
|
||||
class ReportedStatusesController < BaseController
|
||||
def destroy
|
||||
status = Status.find params[:id]
|
||||
before_action :set_report
|
||||
before_action :set_status
|
||||
|
||||
RemovalWorker.perform_async(status.id)
|
||||
redirect_to admin_report_path(report)
|
||||
def update
|
||||
@status.update(status_params)
|
||||
redirect_to admin_report_path(@report)
|
||||
end
|
||||
|
||||
def destroy
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
redirect_to admin_report_path(@report)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def report
|
||||
Report.find(params[:report_id])
|
||||
def status_params
|
||||
params.require(:status).permit(:sensitive)
|
||||
end
|
||||
|
||||
def set_report
|
||||
@report = Report.find(params[:report_id])
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = @report.statuses.find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@@ -23,14 +23,14 @@ class Api::V1::AccountsController < ApiController
|
||||
end
|
||||
|
||||
def following
|
||||
@accounts = Account.includes(:followers)
|
||||
.references(:followers)
|
||||
@accounts = Account.includes(:passive_relationships)
|
||||
.references(:passive_relationships)
|
||||
.merge(Follow.where(account: @account)
|
||||
.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]))
|
||||
.to_a
|
||||
|
||||
next_path = following_api_v1_account_url(pagination_params(max_id: @accounts.last.followers.last.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = following_api_v1_account_url(pagination_params(since_id: @accounts.first.followers.first.id)) unless @accounts.empty?
|
||||
next_path = following_api_v1_account_url(pagination_params(max_id: @accounts.last.passive_relationships.first.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = following_api_v1_account_url(pagination_params(since_id: @accounts.first.passive_relationships.first.id)) unless @accounts.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
|
||||
@@ -38,16 +38,16 @@ class Api::V1::AccountsController < ApiController
|
||||
end
|
||||
|
||||
def followers
|
||||
@accounts = Account.includes(:following)
|
||||
.references(:following)
|
||||
@accounts = Account.includes(:active_relationships)
|
||||
.references(:active_relationships)
|
||||
.merge(Follow.where(target_account: @account)
|
||||
.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
||||
params[:max_id],
|
||||
params[:since_id]))
|
||||
.to_a
|
||||
|
||||
next_path = followers_api_v1_account_url(pagination_params(max_id: @accounts.last.following.last.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = followers_api_v1_account_url(pagination_params(since_id: @accounts.first.following.first.id)) unless @accounts.empty?
|
||||
next_path = followers_api_v1_account_url(pagination_params(max_id: @accounts.last.active_relationships.first.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = followers_api_v1_account_url(pagination_params(since_id: @accounts.first.active_relationships.first.id)) unless @accounts.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::V1::AccountsController < ApiController
|
||||
|
||||
set_maps(@statuses)
|
||||
|
||||
next_path = statuses_api_v1_account_url(statuses_pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
|
||||
next_path = statuses_api_v1_account_url(statuses_pagination_params(max_id: @statuses.last.id)) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||
prev_path = statuses_api_v1_account_url(statuses_pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
|
@@ -7,14 +7,14 @@ class Api::V1::MutesController < ApiController
|
||||
respond_to :json
|
||||
|
||||
def index
|
||||
@accounts = Account.includes(:muting)
|
||||
.references(:muting)
|
||||
@accounts = Account.includes(:muted_by)
|
||||
.references(:muted_by)
|
||||
.merge(Mute.where(account: current_account)
|
||||
.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]))
|
||||
.to_a
|
||||
|
||||
next_path = api_v1_mutes_url(pagination_params(max_id: @accounts.last.mutings_accounts.last.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = api_v1_mutes_url(pagination_params(since_id: @accounts.first.mutings_accounts.first.id)) unless @accounts.empty?
|
||||
next_path = api_v1_mutes_url(pagination_params(max_id: @accounts.last.muted_by_ids.last)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = api_v1_mutes_url(pagination_params(since_id: @accounts.first.muted_by_ids.first)) unless @accounts.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
@@ -47,14 +47,14 @@ class Api::V1::StatusesController < ApiController
|
||||
end
|
||||
|
||||
def favourited_by
|
||||
@accounts = Account.includes(statuses: :favourites)
|
||||
.references(statuses: :favourites)
|
||||
.where(statuses: { id: @status.id })
|
||||
.merge(@status.favourites.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]))
|
||||
@accounts = Account.includes(:favourites)
|
||||
.references(:favourites)
|
||||
.where(favourites: { status_id: @status.id })
|
||||
.merge(Favourite.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]))
|
||||
.to_a
|
||||
|
||||
next_path = favourited_by_api_v1_status_url(pagination_params(max_id: @accounts.last.statuses.last.favourites.last.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = favourited_by_api_v1_status_url(pagination_params(since_id: @accounts.first.statuses.first.favourites.first.id)) unless @accounts.empty?
|
||||
next_path = favourited_by_api_v1_status_url(pagination_params(max_id: @accounts.last.favourites.last.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
prev_path = favourited_by_api_v1_status_url(pagination_params(since_id: @accounts.first.favourites.first.id)) unless @accounts.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
|
||||
|
30
app/controllers/api/v1/timelines/base_controller.rb
Normal file
30
app/controllers/api/v1/timelines/base_controller.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api::V1::Timelines
|
||||
class BaseController < ApiController
|
||||
respond_to :json
|
||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||
|
||||
private
|
||||
|
||||
def cache_collection(raw)
|
||||
super(raw, Status)
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.permit(:local, :limit).merge(core_params)
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
||||
def next_path
|
||||
raise 'Override in child controllers'
|
||||
end
|
||||
|
||||
def prev_path
|
||||
raise 'Override in child controllers'
|
||||
end
|
||||
end
|
||||
end
|
44
app/controllers/api/v1/timelines/home_controller.rb
Normal file
44
app/controllers/api/v1/timelines/home_controller.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api::V1::Timelines
|
||||
class HomeController < BaseController
|
||||
before_action -> { doorkeeper_authorize! :read }, only: [:show]
|
||||
before_action :require_user!, only: [:show]
|
||||
|
||||
def show
|
||||
@statuses = load_statuses
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_statuses
|
||||
cached_home_statuses.tap do |statuses|
|
||||
set_maps(statuses)
|
||||
end
|
||||
end
|
||||
|
||||
def cached_home_statuses
|
||||
cache_collection home_statuses
|
||||
end
|
||||
|
||||
def home_statuses
|
||||
account_home_feed.get(
|
||||
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||
params[:max_id],
|
||||
params[:since_id]
|
||||
)
|
||||
end
|
||||
|
||||
def account_home_feed
|
||||
Feed.new(:home, current_account)
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_timelines_home_url pagination_params(max_id: @statuses.last.id)
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_timelines_home_url pagination_params(since_id: @statuses.first.id)
|
||||
end
|
||||
end
|
||||
end
|
41
app/controllers/api/v1/timelines/public_controller.rb
Normal file
41
app/controllers/api/v1/timelines/public_controller.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api::V1::Timelines
|
||||
class PublicController < BaseController
|
||||
def show
|
||||
@statuses = load_statuses
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_statuses
|
||||
cached_public_statuses.tap do |statuses|
|
||||
set_maps(statuses)
|
||||
end
|
||||
end
|
||||
|
||||
def cached_public_statuses
|
||||
cache_collection public_statuses
|
||||
end
|
||||
|
||||
def public_statuses
|
||||
public_timeline_statuses.paginate_by_max_id(
|
||||
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||
params[:max_id],
|
||||
params[:since_id]
|
||||
)
|
||||
end
|
||||
|
||||
def public_timeline_statuses
|
||||
Status.as_public_timeline(current_account, params[:local])
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_timelines_public_url pagination_params(max_id: @statuses.last.id)
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_timelines_public_url pagination_params(since_id: @statuses.first.id)
|
||||
end
|
||||
end
|
||||
end
|
51
app/controllers/api/v1/timelines/tag_controller.rb
Normal file
51
app/controllers/api/v1/timelines/tag_controller.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api::V1::Timelines
|
||||
class TagController < BaseController
|
||||
before_action :load_tag
|
||||
|
||||
def show
|
||||
@statuses = load_statuses
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_tag
|
||||
@tag = Tag.find_by(name: params[:id].downcase)
|
||||
end
|
||||
|
||||
def load_statuses
|
||||
cached_tagged_statuses.tap do |statuses|
|
||||
set_maps(statuses)
|
||||
end
|
||||
end
|
||||
|
||||
def cached_tagged_statuses
|
||||
cache_collection tagged_statuses
|
||||
end
|
||||
|
||||
def tagged_statuses
|
||||
if @tag.nil?
|
||||
[]
|
||||
else
|
||||
tag_timeline_statuses.paginate_by_max_id(
|
||||
limit_param(DEFAULT_STATUSES_LIMIT),
|
||||
params[:max_id],
|
||||
params[:since_id]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def tag_timeline_statuses
|
||||
Status.as_tag_timeline(@tag, current_account, params[:local])
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_timelines_tag_url params[:id], pagination_params(max_id: @statuses.last.id)
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_timelines_tag_url params[:id], pagination_params(since_id: @statuses.first.id)
|
||||
end
|
||||
end
|
||||
end
|
@@ -1,61 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::TimelinesController < ApiController
|
||||
before_action -> { doorkeeper_authorize! :read }, only: [:home]
|
||||
before_action :require_user!, only: [:home]
|
||||
|
||||
respond_to :json
|
||||
|
||||
def home
|
||||
@statuses = Feed.new(:home, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
|
||||
@statuses = cache_collection(@statuses)
|
||||
|
||||
set_maps(@statuses)
|
||||
|
||||
next_path = api_v1_home_timeline_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
|
||||
prev_path = api_v1_home_timeline_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
|
||||
render :index
|
||||
end
|
||||
|
||||
def public
|
||||
@statuses = Status.as_public_timeline(current_account, params[:local]).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
|
||||
@statuses = cache_collection(@statuses)
|
||||
|
||||
set_maps(@statuses)
|
||||
|
||||
next_path = api_v1_public_timeline_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
|
||||
prev_path = api_v1_public_timeline_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
|
||||
render :index
|
||||
end
|
||||
|
||||
def tag
|
||||
@tag = Tag.find_by(name: params[:id].downcase)
|
||||
@statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
|
||||
@statuses = cache_collection(@statuses)
|
||||
|
||||
set_maps(@statuses)
|
||||
|
||||
next_path = api_v1_hashtag_timeline_url(params[:id], pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
|
||||
prev_path = api_v1_hashtag_timeline_url(params[:id], pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
|
||||
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
|
||||
render :index
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cache_collection(raw)
|
||||
super(raw, Status)
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.permit(:local, :limit).merge(core_params)
|
||||
end
|
||||
end
|
@@ -6,10 +6,15 @@ class Api::Web::SettingsController < ApiController
|
||||
before_action :require_user!
|
||||
|
||||
def update
|
||||
setting = ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user)
|
||||
setting.data = params[:data]
|
||||
setting.save!
|
||||
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setting
|
||||
@_setting ||= ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user)
|
||||
end
|
||||
end
|
||||
|
@@ -39,7 +39,7 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def check_suspension
|
||||
head 403 if current_user.account.suspended?
|
||||
forbidden if current_user.account.suspended?
|
||||
end
|
||||
|
||||
protected
|
||||
@@ -61,7 +61,7 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def single_user_mode?
|
||||
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.first
|
||||
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists?
|
||||
end
|
||||
|
||||
def current_account
|
||||
|
@@ -6,6 +6,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
before_action :check_enabled_registrations, only: [:new, :create]
|
||||
before_action :configure_sign_up_params, only: [:create]
|
||||
|
||||
def destroy
|
||||
not_found
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def build_resource(hash = nil)
|
||||
|
@@ -12,13 +12,13 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
def create
|
||||
super do |resource|
|
||||
remember_me(resource)
|
||||
flash[:notice] = nil
|
||||
flash.delete(:notice)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
super
|
||||
flash[:notice] = nil
|
||||
flash.delete(:notice)
|
||||
end
|
||||
|
||||
protected
|
||||
@@ -35,10 +35,10 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
params.require(:user).permit(:email, :password, :otp_attempt)
|
||||
end
|
||||
|
||||
def after_sign_in_path_for(_resource)
|
||||
def after_sign_in_path_for(resource)
|
||||
last_url = stored_location_for(:user)
|
||||
|
||||
if [about_path].include?(last_url)
|
||||
if home_paths(resource).include?(last_url)
|
||||
root_path
|
||||
else
|
||||
last_url || root_path
|
||||
@@ -81,4 +81,14 @@ class Auth::SessionsController < Devise::SessionsController
|
||||
session[:otp_user_id] = user.id
|
||||
render :two_factor
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def home_paths(resource)
|
||||
paths = [about_path]
|
||||
if single_user_mode? && resource.is_a?(User)
|
||||
paths << short_account_path(username: resource.account)
|
||||
end
|
||||
paths
|
||||
end
|
||||
end
|
||||
|
@@ -4,6 +4,6 @@ class FollowerAccountsController < ApplicationController
|
||||
include AccountControllerConcern
|
||||
|
||||
def index
|
||||
@follows = Follow.where(target_account: @account).order(id: :desc).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)
|
||||
end
|
||||
end
|
||||
|
@@ -4,6 +4,6 @@ class FollowingAccountsController < ApplicationController
|
||||
include AccountControllerConcern
|
||||
|
||||
def index
|
||||
@follows = Follow.where(account: @account).order(id: :desc).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)
|
||||
end
|
||||
end
|
||||
|
@@ -11,6 +11,7 @@ class Settings::PreferencesController < ApplicationController
|
||||
user_settings.update(user_settings_params.to_h)
|
||||
|
||||
if current_user.update(user_params)
|
||||
I18n.locale = current_user.locale
|
||||
redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
|
||||
else
|
||||
render :show
|
||||
|
@@ -11,12 +11,8 @@ class StreamEntriesController < ApplicationController
|
||||
def show
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
return gone if @stream_entry.activity.nil?
|
||||
|
||||
if @stream_entry.activity_type == 'Status'
|
||||
@ancestors = @stream_entry.activity.reply? ? cache_collection(@stream_entry.activity.ancestors(current_account), Status) : []
|
||||
@descendants = cache_collection(@stream_entry.activity.descendants(current_account), Status)
|
||||
end
|
||||
@ancestors = @stream_entry.activity.reply? ? cache_collection(@stream_entry.activity.ancestors(current_account), Status) : []
|
||||
@descendants = cache_collection(@stream_entry.activity.descendants(current_account), Status)
|
||||
end
|
||||
|
||||
format.atom do
|
||||
@@ -46,7 +42,7 @@ class StreamEntriesController < ApplicationController
|
||||
@stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id])
|
||||
@type = @stream_entry.activity_type.downcase
|
||||
|
||||
raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? || (@stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account))))
|
||||
raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? || (@stream_entry.hidden? && !@stream_entry.activity.permitted?(current_account))
|
||||
end
|
||||
|
||||
def check_account_suspension
|
||||
|
@@ -23,7 +23,14 @@ module WellKnown
|
||||
private
|
||||
|
||||
def username_from_resource
|
||||
WebfingerResource.new(resource_param).username
|
||||
resource_user = resource_param
|
||||
|
||||
username, domain = resource_user.split('@')
|
||||
if Rails.configuration.x.alternate_domains.include?(domain)
|
||||
resource_user = "#{username}@#{Rails.configuration.x.local_domain}"
|
||||
end
|
||||
|
||||
WebfingerResource.new(resource_user).username
|
||||
end
|
||||
|
||||
def pem_to_magic_key(public_key)
|
||||
|
17
app/helpers/style_helper.rb
Normal file
17
app/helpers/style_helper.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module StyleHelper
|
||||
def stylesheet_for_layout
|
||||
if asset_exist? 'custom.css'
|
||||
'custom'
|
||||
else
|
||||
'application'
|
||||
end
|
||||
end
|
||||
|
||||
def asset_exist?(path)
|
||||
true if Webpacker::Manifest.lookup(path)
|
||||
rescue Webpacker::FileLoader::NotFoundError
|
||||
false
|
||||
end
|
||||
end
|
BIN
app/javascript/images/elephant-friend-1.png
Normal file
BIN
app/javascript/images/elephant-friend-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 142 KiB |
@@ -115,7 +115,8 @@ export function fetchAccountTimeline(id, replace = false) {
|
||||
dispatch(fetchAccountTimelineRequest(id, skipLoading));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/statuses`, { params }).then(response => {
|
||||
dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading, next));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountTimelineFail(id, error, skipLoading));
|
||||
});
|
||||
@@ -138,7 +139,8 @@ export function fetchAccountMediaTimeline(id, replace = false) {
|
||||
dispatch(fetchAccountMediaTimelineRequest(id, skipLoading));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/statuses`, { params }).then(response => {
|
||||
dispatch(fetchAccountMediaTimelineSuccess(id, response.data, replace, skipLoading));
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchAccountMediaTimelineSuccess(id, response.data, replace, skipLoading, next));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountMediaTimelineFail(id, error, skipLoading));
|
||||
});
|
||||
@@ -283,13 +285,14 @@ export function fetchAccountTimelineRequest(id, skipLoading) {
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
|
||||
export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading, next) {
|
||||
return {
|
||||
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||
id,
|
||||
statuses,
|
||||
replace,
|
||||
skipLoading,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -311,13 +314,14 @@ export function fetchAccountMediaTimelineRequest(id, skipLoading) {
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountMediaTimelineSuccess(id, statuses, replace, skipLoading) {
|
||||
export function fetchAccountMediaTimelineSuccess(id, statuses, replace, skipLoading, next) {
|
||||
return {
|
||||
type: ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS,
|
||||
id,
|
||||
statuses,
|
||||
replace,
|
||||
skipLoading,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -27,22 +27,15 @@ class Account extends ImmutablePureComponent {
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleFollow = this.handleFollow.bind(this);
|
||||
this.handleBlock = this.handleBlock.bind(this);
|
||||
this.handleMute = this.handleMute.bind(this);
|
||||
}
|
||||
|
||||
handleFollow () {
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock () {
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMute () {
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isRtl } from '../rtl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
@@ -51,23 +52,14 @@ class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
autoFocus: true,
|
||||
};
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
suggestionsHidden: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
this.onSuggestionClick = this.onSuggestionClick.bind(this);
|
||||
this.setTextarea = this.setTextarea.bind(this);
|
||||
this.onPaste = this.onPaste.bind(this);
|
||||
}
|
||||
state = {
|
||||
suggestionsHidden: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange (e) {
|
||||
onChange = (e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
@@ -78,14 +70,10 @@ class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
// auto-resize textarea
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown (e) {
|
||||
onKeyDown = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
@@ -135,7 +123,7 @@ class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onBlur () {
|
||||
onBlur = () => {
|
||||
// If we hide the suggestions immediately, then this will prevent the
|
||||
// onClick for the suggestions themselves from firing.
|
||||
// Setting a short window for that to take place before hiding the
|
||||
@@ -145,7 +133,7 @@ class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onSuggestionClick (e) {
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = Number(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
@@ -158,21 +146,17 @@ class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
setTextarea (c) {
|
||||
setTextarea = (c) => {
|
||||
this.textarea = c;
|
||||
}
|
||||
|
||||
onPaste (e) {
|
||||
onPaste = (e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
reset () {
|
||||
this.textarea.style.height = 'auto';
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
|
||||
const { suggestionsHidden, selectedSuggestion } = this.state;
|
||||
@@ -184,8 +168,8 @@ class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<textarea
|
||||
ref={this.setTextarea}
|
||||
<Textarea
|
||||
inputRef={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
|
@@ -19,7 +19,7 @@ class Avatar extends React.PureComponent {
|
||||
};
|
||||
|
||||
state = {
|
||||
hovering: true,
|
||||
hovering: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
class Button extends React.PureComponent {
|
||||
|
||||
@@ -10,6 +11,7 @@ class Button extends React.PureComponent {
|
||||
block: PropTypes.bool,
|
||||
secondary: PropTypes.bool,
|
||||
size: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
@@ -20,10 +22,18 @@ class Button extends React.PureComponent {
|
||||
|
||||
handleClick = (e) => {
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick();
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.node.focus();
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
padding: `0 ${this.props.size / 2.25}px`,
|
||||
@@ -32,11 +42,17 @@ class Button extends React.PureComponent {
|
||||
...this.props.style,
|
||||
};
|
||||
|
||||
const className = classNames('button', this.props.className, {
|
||||
'button-secondary': this.props.secondary,
|
||||
'button--block': this.props.block,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`button ${this.props.secondary ? 'button-secondary' : ''} ${this.props.block ? 'button--block' : ''}`}
|
||||
className={className}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleClick}
|
||||
ref={this.setRef}
|
||||
style={style}
|
||||
>
|
||||
{this.props.text || this.props.children}
|
||||
|
@@ -13,30 +13,35 @@ class ColumnCollapsable extends React.PureComponent {
|
||||
|
||||
state = {
|
||||
collapsed: true,
|
||||
animating: false,
|
||||
};
|
||||
|
||||
handleToggleCollapsed = () => {
|
||||
const currentState = this.state.collapsed;
|
||||
|
||||
this.setState({ collapsed: !currentState });
|
||||
this.setState({ collapsed: !currentState, animating: true });
|
||||
|
||||
if (!currentState && this.props.onCollapse) {
|
||||
this.props.onCollapse();
|
||||
}
|
||||
}
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
this.setState({ animating: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, title, fullHeight, children } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
const { collapsed, animating } = this.state;
|
||||
|
||||
return (
|
||||
<div className={`column-collapsable ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className={`column-collapsable ${collapsed ? 'collapsed' : ''}`} onTransitionEnd={this.handleTransitionEnd}>
|
||||
<div role='button' tabIndex='0' title={`${title}`} className='column-collapsable__button column-icon' onClick={this.handleToggleCollapsed}>
|
||||
<i className={`fa fa-${icon}`} />
|
||||
</div>
|
||||
|
||||
<div className='column-collapsable__content' style={{ height: `${fullHeight}px` }}>
|
||||
{!collapsed && children}
|
||||
{(!collapsed || animating) && children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -33,11 +33,14 @@ class DropdownMenu extends React.PureComponent {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
e.preventDefault();
|
||||
// Don't call e.preventDefault() when the item uses 'href' property.
|
||||
// ex. "Edit profile" on the account action bar
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(to);
|
||||
}
|
||||
|
||||
|
@@ -3,9 +3,9 @@ import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const LoadMore = ({ onClick }) => (
|
||||
<a href="#" className='load-more' role='button' onClick={onClick}>
|
||||
<button className='load-more' onClick={onClick}>
|
||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
|
||||
LoadMore.propTypes = {
|
||||
|
@@ -2,19 +2,40 @@ import React from 'react';
|
||||
import { injectIntl, FormattedRelative } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const RelativeTimestamp = ({ intl, timestamp }) => {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
|
||||
<FormattedRelative value={date} />
|
||||
</time>
|
||||
);
|
||||
const dateFormatOptions = {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
RelativeTimestamp.propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
};
|
||||
class RelativeTimestamp extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
shouldComponentUpdate (nextProps) {
|
||||
// As of right now the locale doesn't change without a new page load,
|
||||
// but we might as well check in case that ever changes.
|
||||
return this.props.timestamp !== nextProps.timestamp ||
|
||||
this.props.intl.locale !== nextProps.intl.locale;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { timestamp, intl } = this.props;
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
||||
<FormattedRelative value={date} />
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(RelativeTimestamp);
|
||||
|
@@ -32,12 +32,65 @@ class Status extends ImmutablePureComponent {
|
||||
onOpenMedia: PropTypes.func,
|
||||
onOpenVideo: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onRef: PropTypes.func,
|
||||
isIntersecting: PropTypes.bool,
|
||||
me: PropTypes.number,
|
||||
boostModal: PropTypes.bool,
|
||||
autoPlayGif: PropTypes.bool,
|
||||
muted: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
isHidden: false,
|
||||
}
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'account',
|
||||
'wrapped',
|
||||
'me',
|
||||
'boostModal',
|
||||
'autoPlayGif',
|
||||
'muted',
|
||||
]
|
||||
|
||||
updateOnStates = []
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
if (nextProps.isIntersecting === false && nextState.isHidden) {
|
||||
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
|
||||
// that either "isIntersecting" or "isHidden" matter, and then they're
|
||||
// the only things that matter.
|
||||
return this.props.isIntersecting !== false || !this.state.isHidden;
|
||||
} else if (nextProps.isIntersecting !== false && this.props.isIntersecting === false) {
|
||||
// If we're going from a non-intersecting state to an intersecting state,
|
||||
// (i.e. offscreen to onscreen), then we definitely need to re-render
|
||||
return true;
|
||||
}
|
||||
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
|
||||
return super.shouldComponentUpdate(nextProps, nextState);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) {
|
||||
requestIdleCallback(() => this.setState({ isHidden: true }));
|
||||
} else {
|
||||
this.setState({ isHidden: !nextProps.isIntersecting });
|
||||
}
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
if (this.props.onRef) {
|
||||
this.props.onRef(node);
|
||||
|
||||
if (node && node.children.length !== 0) {
|
||||
this.height = node.clientHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
const { status } = this.props;
|
||||
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
||||
@@ -52,12 +105,22 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
let media = '';
|
||||
let media = null;
|
||||
let statusAvatar;
|
||||
const { status, account, ...other } = this.props;
|
||||
const { status, account, isIntersecting, onRef, ...other } = this.props;
|
||||
const { isHidden } = this.state;
|
||||
|
||||
if (status === null) {
|
||||
return <div />;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isIntersecting === false && isHidden) {
|
||||
return (
|
||||
<div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
|
||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
||||
{status.get('content')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
@@ -70,7 +133,7 @@ class Status extends ImmutablePureComponent {
|
||||
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||
|
||||
return (
|
||||
<div className='status__wrapper'>
|
||||
<div className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} >
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
|
||||
@@ -98,7 +161,7 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`}>
|
||||
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}>
|
||||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import IconButton from './icon_button';
|
||||
import DropdownMenu from './dropdown_menu';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
@@ -21,7 +22,7 @@ const messages = defineMessages({
|
||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
});
|
||||
|
||||
class StatusActionBar extends React.PureComponent {
|
||||
class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
@@ -43,6 +44,14 @@ class StatusActionBar extends React.PureComponent {
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'me',
|
||||
'withDismiss',
|
||||
]
|
||||
|
||||
handleReplyClick = () => {
|
||||
this.props.onReply(this.props.status, this.context.router);
|
||||
}
|
||||
@@ -128,9 +137,9 @@ class StatusActionBar extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<div className='status__action-bar-button-wrapper'><IconButton title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /></div>
|
||||
<div className='status__action-bar-button-wrapper'><IconButton disabled={reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
|
||||
<div className='status__action-bar-button-wrapper'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} className='star-icon' /></div>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" ariaLabel="More"/>
|
||||
|
@@ -65,6 +65,10 @@ class StatusContent extends React.PureComponent {
|
||||
}
|
||||
|
||||
handleMouseUp = (e) => {
|
||||
if (!this.startXY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [ startX, startY ] = this.startXY;
|
||||
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
|
||||
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import Status from './status';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -27,6 +26,13 @@ class StatusList extends ImmutablePureComponent {
|
||||
trackScroll: true,
|
||||
};
|
||||
|
||||
state = {
|
||||
isIntersecting: {},
|
||||
intersectionCount: 0,
|
||||
}
|
||||
|
||||
statusRefQueue = []
|
||||
|
||||
handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const offset = scrollHeight - scrollTop - clientHeight;
|
||||
@@ -43,6 +49,7 @@ class StatusList extends ImmutablePureComponent {
|
||||
|
||||
componentDidMount () {
|
||||
this.attachScrollListener();
|
||||
this.attachIntersectionObserver();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
@@ -53,6 +60,57 @@ class StatusList extends ImmutablePureComponent {
|
||||
|
||||
componentWillUnmount () {
|
||||
this.detachScrollListener();
|
||||
this.detachIntersectionObserver();
|
||||
}
|
||||
|
||||
attachIntersectionObserver () {
|
||||
const onIntersection = (entries) => {
|
||||
this.setState(state => {
|
||||
|
||||
entries.forEach(entry => {
|
||||
const statusId = entry.target.getAttribute('data-id');
|
||||
|
||||
// Edge 15 doesn't support isIntersecting, but we can infer it from intersectionRatio
|
||||
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/
|
||||
state.isIntersecting[statusId] = entry.intersectionRatio > 0;
|
||||
});
|
||||
|
||||
// isIntersecting is a map of DOM data-id's to booleans (true for
|
||||
// intersecting, false for non-intersecting).
|
||||
//
|
||||
// We always want to return true in shouldComponentUpdate() if
|
||||
// this object changes, because onIntersection() is only called if
|
||||
// something has changed.
|
||||
//
|
||||
// Now, we *could* use an immutable map or some other structure to
|
||||
// diff the full map, but that would be pointless because the browser
|
||||
// has already informed us that something has changed. So we can just
|
||||
// use a regular object, which will be diffed by ImmutablePureComponent
|
||||
// based on reference equality (i.e. it's always "unchanged") and
|
||||
// then we just increment intersectionCount to force a change.
|
||||
|
||||
return {
|
||||
isIntersecting: state.isIntersecting,
|
||||
intersectionCount: state.intersectionCount + 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const options = {
|
||||
root: this.node,
|
||||
rootMargin: '300% 0px',
|
||||
};
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver(onIntersection, options);
|
||||
|
||||
if (this.statusRefQueue.length) {
|
||||
this.statusRefQueue.forEach(node => this.intersectionObserver.observe(node));
|
||||
this.statusRefQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
detachIntersectionObserver () {
|
||||
this.intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
attachScrollListener () {
|
||||
@@ -67,6 +125,15 @@ class StatusList extends ImmutablePureComponent {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleStatusRef = (node) => {
|
||||
if (node && this.intersectionObserver) {
|
||||
const statusId = node.getAttribute('data-id');
|
||||
this.intersectionObserver.observe(node);
|
||||
} else {
|
||||
this.statusRefQueue.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = (e) => {
|
||||
e.preventDefault();
|
||||
this.props.onScrollToBottom();
|
||||
@@ -74,10 +141,11 @@ class StatusList extends ImmutablePureComponent {
|
||||
|
||||
render () {
|
||||
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
|
||||
const { isIntersecting } = this.state;
|
||||
|
||||
let loadMore = '';
|
||||
let scrollableArea = '';
|
||||
let unread = '';
|
||||
let loadMore = null;
|
||||
let scrollableArea = null;
|
||||
let unread = null;
|
||||
|
||||
if (!isLoading && statusIds.size > 0 && hasMore) {
|
||||
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
||||
@@ -96,7 +164,7 @@ class StatusList extends ImmutablePureComponent {
|
||||
{prepend}
|
||||
|
||||
{statusIds.map((statusId) => {
|
||||
return <StatusContainer key={statusId} id={statusId} />;
|
||||
return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />;
|
||||
})}
|
||||
|
||||
{loadMore}
|
||||
|
@@ -41,34 +41,12 @@ import FavouritedStatuses from '../features/favourited_statuses';
|
||||
import Blocks from '../features/blocks';
|
||||
import Mutes from '../features/mutes';
|
||||
import Report from '../features/report';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import ar from 'react-intl/locale-data/ar';
|
||||
import bg from 'react-intl/locale-data/bg';
|
||||
import ca from 'react-intl/locale-data/ca';
|
||||
import de from 'react-intl/locale-data/de';
|
||||
import en from 'react-intl/locale-data/en';
|
||||
import eo from 'react-intl/locale-data/eo';
|
||||
import es from 'react-intl/locale-data/es';
|
||||
import fa from 'react-intl/locale-data/fa';
|
||||
import fi from 'react-intl/locale-data/fi';
|
||||
import fr from 'react-intl/locale-data/fr';
|
||||
import he from 'react-intl/locale-data/he';
|
||||
import hr from 'react-intl/locale-data/hr';
|
||||
import hu from 'react-intl/locale-data/hu';
|
||||
import id from 'react-intl/locale-data/id';
|
||||
import it from 'react-intl/locale-data/it';
|
||||
import ja from 'react-intl/locale-data/ja';
|
||||
import nl from 'react-intl/locale-data/nl';
|
||||
import no from 'react-intl/locale-data/no';
|
||||
import oc from '../locales/locale-data/oc';
|
||||
import pt from 'react-intl/locale-data/pt';
|
||||
import ru from 'react-intl/locale-data/ru';
|
||||
import uk from 'react-intl/locale-data/uk';
|
||||
import zh from 'react-intl/locale-data/zh';
|
||||
import tr from 'react-intl/locale-data/tr';
|
||||
import getMessagesForLocale from '../locales';
|
||||
import { hydrateStore } from '../actions/store';
|
||||
import createStream from '../stream';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
const store = configureStore();
|
||||
const initialState = JSON.parse(document.getElementById("initial-state").textContent);
|
||||
@@ -78,33 +56,6 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
|
||||
basename: '/web',
|
||||
});
|
||||
|
||||
addLocaleData([
|
||||
...ar,
|
||||
...bg,
|
||||
...ca,
|
||||
...de,
|
||||
...en,
|
||||
...eo,
|
||||
...es,
|
||||
...fa,
|
||||
...fi,
|
||||
...fr,
|
||||
...he,
|
||||
...hr,
|
||||
...hu,
|
||||
...id,
|
||||
...it,
|
||||
...ja,
|
||||
...nl,
|
||||
...no,
|
||||
...oc,
|
||||
...pt,
|
||||
...ru,
|
||||
...uk,
|
||||
...zh,
|
||||
...tr,
|
||||
]);
|
||||
|
||||
class Mastodon extends React.PureComponent {
|
||||
|
||||
componentDidMount() {
|
||||
@@ -145,7 +96,7 @@ class Mastodon extends React.PureComponent {
|
||||
store.dispatch(deleteFromTimelines(data.payload));
|
||||
break;
|
||||
case 'notification':
|
||||
store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale));
|
||||
store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -183,7 +134,7 @@ class Mastodon extends React.PureComponent {
|
||||
const { locale } = this.props;
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={getMessagesForLocale(locale)}>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Provider store={store}>
|
||||
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
|
||||
<Route path='/' component={UI}>
|
||||
|
2
app/javascript/mastodon/extra_polyfills.js
Normal file
2
app/javascript/mastodon/extra_polyfills.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import 'intersection-observer';
|
||||
import 'requestidlecallback';
|
@@ -17,6 +17,7 @@ import MediaItem from './components/media_item';
|
||||
import HeaderContainer from '../account_timeline/containers/header_container';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { ScrollContainer } from 'react-router-scroll';
|
||||
import LoadMore from '../../components/load_more';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
medias: getAccountGallery(state, Number(props.params.accountId)),
|
||||
@@ -48,16 +49,30 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) {
|
||||
handleScrollToBottom = () => {
|
||||
if (this.props.hasMore) {
|
||||
this.props.dispatch(expandAccountMediaTimeline(Number(this.props.params.accountId)));
|
||||
}
|
||||
}
|
||||
|
||||
handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const offset = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
if (150 > offset && !this.props.isLoading) {
|
||||
this.handleScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = (e) => {
|
||||
e.preventDefault();
|
||||
this.handleScrollToBottom();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { medias, autoPlayGif, isLoading } = this.props;
|
||||
const { medias, autoPlayGif, isLoading, hasMore } = this.props;
|
||||
|
||||
let loadMore = null;
|
||||
|
||||
if (!medias && isLoading) {
|
||||
return (
|
||||
@@ -67,6 +82,10 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && medias.size > 0 && hasMore) {
|
||||
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
@@ -87,6 +106,7 @@ class AccountGallery extends ImmutablePureComponent {
|
||||
autoPlayGif={autoPlayGif}
|
||||
/>
|
||||
)}
|
||||
{loadMore}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
@@ -67,7 +67,6 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
this.autosuggestTextarea.reset();
|
||||
this.props.onSubmit();
|
||||
}
|
||||
|
||||
|
@@ -49,7 +49,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
||||
this.setState({active: true});
|
||||
if (!EmojiPicker) {
|
||||
this.setState({loading: true});
|
||||
import('emojione-picker').then(TheEmojiPicker => {
|
||||
import(/* webpackChunkName: "emojione_picker" */ 'emojione-picker').then(TheEmojiPicker => {
|
||||
EmojiPicker = TheEmojiPicker.default;
|
||||
this.setState({loading: false});
|
||||
}).catch(err => {
|
||||
|
@@ -3,6 +3,8 @@ import IconButton from '../../../components/icon_button';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
|
||||
@@ -10,7 +12,7 @@ const messages = defineMessages({
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const mapStateToProps = (state, props) => ({
|
||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray(),
|
||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
@@ -21,14 +23,14 @@ const iconStyle = {
|
||||
lineHeight: '27px',
|
||||
};
|
||||
|
||||
class UploadButton extends React.PureComponent {
|
||||
class UploadButton extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
disabled: PropTypes.bool,
|
||||
onSelectFile: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
resetFileKey: PropTypes.number,
|
||||
acceptContentTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
@@ -58,7 +60,7 @@ class UploadButton extends React.PureComponent {
|
||||
ref={this.setRef}
|
||||
type='file'
|
||||
multiple={false}
|
||||
accept={ acceptContentTypes.join(',')}
|
||||
accept={ acceptContentTypes.toArray().join(',')}
|
||||
onChange={this.handleChange}
|
||||
disabled={disabled}
|
||||
style={{ display: 'none' }}
|
||||
|
@@ -27,12 +27,6 @@ class BoostModal extends ImmutablePureComponent {
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
constructor (props, context) {
|
||||
super(props, context);
|
||||
this.handleReblog = this.handleReblog.bind(this);
|
||||
this.handleAccountClick = this.handleAccountClick.bind(this);
|
||||
}
|
||||
|
||||
handleReblog = () => {
|
||||
this.props.onReblog(this.props.status);
|
||||
this.props.onClose();
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import ColumnHeader from './column_header';
|
||||
import PropTypes from 'prop-types';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b;
|
||||
|
||||
@@ -48,11 +49,11 @@ class Column extends React.PureComponent {
|
||||
this._interruptScrollAnimation = scrollTop(scrollable);
|
||||
}
|
||||
|
||||
handleWheel = () => {
|
||||
handleScroll = debounce(() => {
|
||||
if (typeof this._interruptScrollAnimation !== 'undefined') {
|
||||
this._interruptScrollAnimation();
|
||||
}
|
||||
}
|
||||
}, 200)
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
@@ -74,7 +75,7 @@ class Column extends React.PureComponent {
|
||||
role='region'
|
||||
aria-labelledby={columnHeaderId}
|
||||
className='column'
|
||||
onWheel={this.handleWheel}>
|
||||
onScroll={this.handleScroll}>
|
||||
{header}
|
||||
{children}
|
||||
</div>
|
||||
|
@@ -13,18 +13,25 @@ class ConfirmationModal extends React.PureComponent {
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.button.focus();
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onClose();
|
||||
this.props.onConfirm();
|
||||
}
|
||||
|
||||
handleCancel = (e) => {
|
||||
e.preventDefault();
|
||||
handleCancel = () => {
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.button = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, message, confirm, onConfirm, onClose } = this.props;
|
||||
const { message, confirm } = this.props;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal confirmation-modal'>
|
||||
@@ -33,8 +40,10 @@ class ConfirmationModal extends React.PureComponent {
|
||||
</div>
|
||||
|
||||
<div className='confirmation-modal__action-bar'>
|
||||
<div><a href='#' onClick={this.handleCancel}><FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /></a></div>
|
||||
<Button text={confirm} onClick={this.handleClick} />
|
||||
<Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'>
|
||||
<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
|
||||
</Button>
|
||||
<Button text={confirm} onClick={this.handleClick} ref={this.setRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -191,15 +191,14 @@ class OnboardingModal extends React.PureComponent {
|
||||
this.setState({ currentIndex: i });
|
||||
}
|
||||
|
||||
handleNext = (e) => {
|
||||
const maxNum = Number(e.currentTarget.getAttribute('data-length'));
|
||||
e.preventDefault();
|
||||
handleNext = () => {
|
||||
this.setState(({ currentIndex }) => ({
|
||||
currentIndex: currentIndex + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
if (this.state.currentIndex < maxNum - 1) {
|
||||
this.setState({ currentIndex: this.state.currentIndex + 1 });
|
||||
} else {
|
||||
this.props.onClose();
|
||||
}
|
||||
handleClose = () => {
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
render () {
|
||||
@@ -216,13 +215,21 @@ class OnboardingModal extends React.PureComponent {
|
||||
const { currentIndex } = this.state;
|
||||
const hasMore = currentIndex < pages.length - 1;
|
||||
|
||||
let nextOrDoneBtn;
|
||||
|
||||
if(hasMore) {
|
||||
nextOrDoneBtn = <a href='#' data-length={pages.length} onClick={this.handleNext} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>;
|
||||
} else {
|
||||
nextOrDoneBtn = <a href='#' data-length={pages.length} onClick={this.handleNext} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.done' defaultMessage='Done' /></a>;
|
||||
}
|
||||
const nextOrDoneBtn = hasMore ? (
|
||||
<button
|
||||
onClick={this.handleNext}
|
||||
className='onboarding-modal__nav onboarding-modal__next'
|
||||
>
|
||||
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={this.handleClose}
|
||||
className='onboarding-modal__nav onboarding-modal__done'
|
||||
>
|
||||
<FormattedMessage id='onboarding.done' defaultMessage='Done' />
|
||||
</button>
|
||||
);
|
||||
|
||||
const styles = pages.map((page, i) => ({
|
||||
key: `page-${i}`,
|
||||
@@ -243,7 +250,12 @@ class OnboardingModal extends React.PureComponent {
|
||||
|
||||
<div className='onboarding-modal__paginator'>
|
||||
<div>
|
||||
<a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a>
|
||||
<button
|
||||
onClick={this.handleSkip}
|
||||
className='onboarding-modal__nav onboarding-modal__skip'
|
||||
>
|
||||
<FormattedMessage id='onboarding.skip' defaultMessage='Skip' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='onboarding-modal__dots'>
|
||||
|
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"account.block": "مسدودسازی @{name}",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.block_domain": "پنهانسازی همه چیز از سرور {domain}",
|
||||
"account.disclaimer": "این کاربر عضو سرور متفاوتی است. شاید عدد واقعی بیشتر از این باشد.",
|
||||
"account.edit_profile": "ویرایش نمایه",
|
||||
"account.follow": "پی بگیرید",
|
||||
"account.followers": "پیگیران",
|
||||
"account.follows": "پی میگیرد",
|
||||
"account.follows_you": "پیگیر شماست",
|
||||
"account.media": "Media",
|
||||
"account.media": "رسانه",
|
||||
"account.mention": "نامبردن از @{name}",
|
||||
"account.mute": "بیصدا کردن @{name}",
|
||||
"account.posts": "نوشتهها",
|
||||
"account.report": "گزارش @{name}",
|
||||
"account.requested": "در انتظار پذیرش",
|
||||
"account.unblock": "رفع انسداد @{name}",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock_domain": "رفع پنهانسازی از {domain}",
|
||||
"account.unfollow": "پایان پیگیری",
|
||||
"account.unmute": "باصدا کردن @{name}",
|
||||
"boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
|
||||
@@ -43,8 +43,8 @@
|
||||
"confirmations.block.message": "آیا واقعاً میخواهید {name} را مسدود کنید؟",
|
||||
"confirmations.delete.confirm": "پاک کن",
|
||||
"confirmations.delete.message": "آیا واقعاً میخواهید این نوشته را پاک کنید؟",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.domain_block.confirm": "پنهانسازی کل دامین",
|
||||
"confirmations.domain_block.message": "آیا جدی جدی میخواهید کل دامین {domain} را مسدود کنید؟ بیشتر وقتها مسدودکردن یا بیصداکردن چند حساب کاربری خاص کافی است و توصیه میشود.",
|
||||
"confirmations.mute.confirm": "بیصدا کن",
|
||||
"confirmations.mute.message": "آیا واقعاً میخواهید {name} را بیصدا کنید؟",
|
||||
"emoji_button.activity": "فعالیت",
|
||||
@@ -60,18 +60,18 @@
|
||||
"empty_column.community": "فهرست نوشتههای محلی خالی است. چیزی بنویسید تا چرخش بچرخد!",
|
||||
"empty_column.hashtag": "هنوز هیچ چیزی با این هشتگ نیست.",
|
||||
"empty_column.home": "شما هنوز پیگیر کسی نیستید. {public} را ببینید یا چیزی را جستجو کنید تا کاربران دیگر را ببینید.",
|
||||
"empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
|
||||
"empty_column.home.inactivity": "فهرست پیگیریهای شما خالی است. اگر مدتی است که غیرفعال بودید، این فهرست به زودی برایتان پر میشود.",
|
||||
"empty_column.home.public_timeline": "فهرست نوشتههای همهجا",
|
||||
"empty_column.notifications": "هنوز هیچ اعلانی ندارید. به نوشتههای دیگران واکنش نشان دهید تا گفتگو آغاز شود.",
|
||||
"empty_column.public": "اینجا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران دیگر را پی بگیرید تا اینجا پر شود",
|
||||
"follow_request.authorize": "اجازه دهید",
|
||||
"follow_request.reject": "اجازه ندهید",
|
||||
"getting_started.appsshort": "Apps",
|
||||
"getting_started.faq": "FAQ",
|
||||
"getting_started.appsshort": "اپها",
|
||||
"getting_started.faq": "پرسشهای رایج",
|
||||
"getting_started.heading": "آغاز کنید",
|
||||
"getting_started.open_source_notice": "ماستدون یک نرمافزار آزاد است. میتوانید در ساخت آن مشارکت کنید یا مشکلاتش را در {github} گزارش دهید.",
|
||||
"getting_started.support": "{faq} • {userguide} • {apps}",
|
||||
"getting_started.userguide": "User Guide",
|
||||
"getting_started.userguide": "راهنمای کاربری",
|
||||
"home.column_settings.advanced": "پیشرفته",
|
||||
"home.column_settings.basic": "اصلی",
|
||||
"home.column_settings.filter_regex": "با عبارتهای باقاعده فیلتر کنید",
|
||||
@@ -92,10 +92,10 @@
|
||||
"navigation_bar.mutes": "کاربران بیصداشده",
|
||||
"navigation_bar.preferences": "ترجیحات",
|
||||
"navigation_bar.public_timeline": "نوشتههای همهجا",
|
||||
"notification.favourite": "{name} نوشتهٔ شما را پسندید",
|
||||
"notification.follow": "{name} پیگیر شما شد",
|
||||
"notification.mention": "{name} از شما نام برد",
|
||||
"notification.reblog": "{name} نوشتهٔ شما را بازبوقید",
|
||||
"notification.favourite": "{name} نوشتهٔ شما را پسندید",
|
||||
"notification.follow": "{name} پیگیر شما شد",
|
||||
"notification.mention": "{name} از شما نام برد",
|
||||
"notification.reblog": "{name} نوشتهٔ شما را بازبوقید",
|
||||
"notifications.clear": "پاککردن اعلانها",
|
||||
"notifications.clear_confirmation": "واقعاً میخواهید همهٔ اعلانهایتان را برای همیشه پاک کنید؟",
|
||||
"notifications.column_settings.alert": "اعلان در کامپیوتر",
|
||||
@@ -103,8 +103,8 @@
|
||||
"notifications.column_settings.follow": "پیگیران تازه:",
|
||||
"notifications.column_settings.mention": "نامبردنها:",
|
||||
"notifications.column_settings.reblog": "بازبوقها:",
|
||||
"notifications.column_settings.show": "در ستون نشان بده",
|
||||
"notifications.column_settings.sound": "صدا را پخش کن",
|
||||
"notifications.column_settings.show": "نمایش در ستون",
|
||||
"notifications.column_settings.sound": "پخش صدا",
|
||||
"notifications.settings": "تنظیمات ستون",
|
||||
"onboarding.done": "پایان",
|
||||
"onboarding.next": "بعدی",
|
||||
@@ -114,7 +114,7 @@
|
||||
"onboarding.page_one.federation": "ماستدون شبکهای از سرورهای مستقل است که با پیوستن به یکدیگر یک شبکهٔ اجتماعی بزرگ را تشکیل میدهند.",
|
||||
"onboarding.page_one.handle": "شما روی سرور {domain} هستید، بنابراین شناسهٔ کامل شما {handle} است.",
|
||||
"onboarding.page_one.welcome": "به ماستدون خوش آمدید!",
|
||||
"onboarding.page_six.admin": "نشانی سرور شما {admin} است.",
|
||||
"onboarding.page_six.admin": "نشانی مسئول سرور شما {admin} است.",
|
||||
"onboarding.page_six.almost_done": "الان تقریباً آمادهاید...",
|
||||
"onboarding.page_six.appetoot": "بوق! بوق!",
|
||||
"onboarding.page_six.apps_available": "اپهای گوناگونی برای اندروید، iOS، و سیستمهای دیگر موجود است.",
|
||||
@@ -123,17 +123,17 @@
|
||||
"onboarding.page_six.read_guidelines": "لطفاً {guidelines} {domain} را بخوانید!",
|
||||
"onboarding.page_six.various_app": "اپهای موبایل",
|
||||
"onboarding.page_three.profile": "با ویرایش نمایه میتوانید تصویر نمایه، نوشتهٔ معرفی، و نام نمایشی خود را تغییر دهید. ترجیحات دیگر شما هم آنجاست.",
|
||||
"onboarding.page_three.search": "در نوار جستجو میتوانید کاربران دیگر را بیابید یا هشتگها را ببینید، مانند {نقاشی} یا {معرفی}. برای یافتن افرادی که روی سرورهای دیگر هستند، شناسهٔ کامل آنها را بنویسید.",
|
||||
"onboarding.page_two.compose": "در ستون «نوشتن» میتوانید نوشتههای تازه بنویسید. همچنین با دکمههای زیر میتوانید تصویر اضافه کنید، حریم خصوصی نوشته را تنظیم کنید، و هشدار محتوا بگذارید.",
|
||||
"onboarding.page_three.search": "در نوار جستجو میتوانید کاربران دیگر را بیابید یا هشتگها را ببینید، مانند {illustration} یا {introductions}. برای یافتن افرادی که روی سرورهای دیگر هستند، شناسهٔ کامل آنها را بنویسید.",
|
||||
"onboarding.page_two.compose": "در ستون «نوشتن» میتوانید نوشتههای تازه بنویسید. همچنین با دکمههای زیرش میتوانید تصویر اضافه کنید، حریم خصوصی نوشته را تنظیم کنید، و هشدار محتوا بگذارید.",
|
||||
"onboarding.skip": "رد کن",
|
||||
"privacy.change": "تنظیم حریم خصوصی نوشتهها",
|
||||
"privacy.direct.long": "تنها به کاربران نامبردهشده نشان بده",
|
||||
"privacy.direct.short": "مستقیم",
|
||||
"privacy.private.long": "تنها به پیگیران نشان بده",
|
||||
"privacy.private.short": "خصوصی",
|
||||
"privacy.public.long": "در فهرست نوشتههای عمومی نشان بده",
|
||||
"privacy.public.long": "در فهرست عمومی نشان بده",
|
||||
"privacy.public.short": "عمومی",
|
||||
"privacy.unlisted.long": "در فهرست نوشتههای همهجا نشان نده",
|
||||
"privacy.unlisted.long": "عمومی، ولی فهرست نکن",
|
||||
"privacy.unlisted.short": "فهرستنشده",
|
||||
"reply_indicator.cancel": "لغو",
|
||||
"report.heading": "گزارش تازه",
|
||||
@@ -144,14 +144,14 @@
|
||||
"search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
|
||||
"status.cannot_reblog": "این نوشته را نمیشود بازبوقید",
|
||||
"status.delete": "پاککردن",
|
||||
"status.favourite": "پسندیدم",
|
||||
"status.favourite": "پسندیدن",
|
||||
"status.load_more": "بیشتر نشان بده",
|
||||
"status.media_hidden": "تصویر پنهان شده",
|
||||
"status.mention": "نامبردن از @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.mute_conversation": "بیصداکردن گفتگو",
|
||||
"status.open": "این نوشته را باز کن",
|
||||
"status.reblog": "بازبوقیدن",
|
||||
"status.reblogged_by": "{name} بازبوقید",
|
||||
"status.reblogged_by": "{name} بازبوقید",
|
||||
"status.reply": "پاسخ",
|
||||
"status.replyAll": "به نوشته پاسخ دهید",
|
||||
"status.report": "گزارش دادن @{name}",
|
||||
@@ -159,7 +159,7 @@
|
||||
"status.sensitive_warning": "محتوای حساس",
|
||||
"status.show_less": "نهفتن",
|
||||
"status.show_more": "نمایش",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unmute_conversation": "باصداکردن گفتگو",
|
||||
"tabs_bar.compose": "بنویسید",
|
||||
"tabs_bar.federated_timeline": "همگانی",
|
||||
"tabs_bar.home": "خانه",
|
||||
|
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"account.block": "חסימת @{name}",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.block_domain": "להסתיר הכל מהקהילה {domain}",
|
||||
"account.disclaimer": "משתמש זה מגיע מקהילה אחרת. המספר הזה עשוי להיות גדול יותר.",
|
||||
"account.edit_profile": "עריכת פרופיל",
|
||||
"account.follow": "מעקב",
|
||||
"account.followers": "עוקבים",
|
||||
"account.follows": "נעקבים",
|
||||
"account.follows_you": "במעקב אחריך",
|
||||
"account.media": "Media",
|
||||
"account.media": "מדיה",
|
||||
"account.mention": "אזכור של @{name}",
|
||||
"account.mute": "להשתיק את @{name}",
|
||||
"account.posts": "הודעות",
|
||||
"account.report": "לדווח על @{name}",
|
||||
"account.requested": "בהמתנה לאישור",
|
||||
"account.unblock": "הסרת חסימה מעל @{name}",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock_domain": "הסר חסימה מקהילת {domain}",
|
||||
"account.unfollow": "הפסקת מעקב",
|
||||
"account.unmute": "הפסקת השתקת @{name}",
|
||||
"boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה",
|
||||
@@ -43,8 +43,8 @@
|
||||
"confirmations.block.message": "לחסום את {name}?",
|
||||
"confirmations.delete.confirm": "למחוק",
|
||||
"confirmations.delete.message": "למחוק את ההודעה?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.domain_block.confirm": "הסתר קהילה שלמה",
|
||||
"confirmations.domain_block.message": "באמת באמת לחסום את כל קהילת {domain}? ברב המקרים השתקות נבחרות של מספר משתמשים מסויימים צריכה להספיק.",
|
||||
"confirmations.mute.confirm": "להשתיק",
|
||||
"confirmations.mute.message": "להשתיק את {name}?",
|
||||
"emoji_button.activity": "פעילות",
|
||||
|
@@ -1,61 +1,9 @@
|
||||
import ar from './ar.json';
|
||||
import en from './en.json';
|
||||
import ca from './ca.json';
|
||||
import de from './de.json';
|
||||
import es from './es.json';
|
||||
import fa from './fa.json';
|
||||
import he from './he.json';
|
||||
import hr from './hr.json';
|
||||
import hu from './hu.json';
|
||||
import io from './io.json';
|
||||
import it from './it.json';
|
||||
import fr from './fr.json';
|
||||
import nl from './nl.json';
|
||||
import no from './no.json';
|
||||
import oc from './oc.json';
|
||||
import pt from './pt.json';
|
||||
import pt_br from './pt-BR.json';
|
||||
import uk from './uk.json';
|
||||
import fi from './fi.json';
|
||||
import eo from './eo.json';
|
||||
import ru from './ru.json';
|
||||
import ja from './ja.json';
|
||||
import zh_hk from './zh-HK.json';
|
||||
import zh_cn from './zh-CN.json';
|
||||
import bg from './bg.json';
|
||||
import id from './id.json';
|
||||
import tr from './tr.json';
|
||||
let theLocale;
|
||||
|
||||
const locales = {
|
||||
ar,
|
||||
en,
|
||||
ca,
|
||||
de,
|
||||
es,
|
||||
fa,
|
||||
he,
|
||||
hr,
|
||||
hu,
|
||||
io,
|
||||
it,
|
||||
fr,
|
||||
nl,
|
||||
no,
|
||||
oc,
|
||||
pt,
|
||||
'pt-BR': pt_br,
|
||||
uk,
|
||||
fi,
|
||||
eo,
|
||||
ru,
|
||||
ja,
|
||||
'zh-HK': zh_hk,
|
||||
'zh-CN': zh_cn,
|
||||
bg,
|
||||
id,
|
||||
tr,
|
||||
};
|
||||
export function setLocale(locale) {
|
||||
theLocale = locale;
|
||||
}
|
||||
|
||||
export default function getMessagesForLocale(locale) {
|
||||
return locales[locale];
|
||||
};
|
||||
export function getLocale() {
|
||||
return theLocale;
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"account.block": "Blokkeer @{name}",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.block_domain": "Negeer alles van {domain}",
|
||||
"account.disclaimer": "Deze gebruiker zit op een andere server. Dit getal kan hoger zijn.",
|
||||
"account.edit_profile": "Profiel bewerken",
|
||||
"account.follow": "Volgen",
|
||||
@@ -14,9 +14,9 @@
|
||||
"account.report": "Rapporteer @{name}",
|
||||
"account.requested": "Wacht op goedkeuring",
|
||||
"account.unblock": "Deblokkeer @{name}",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock_domain": "{domain} niet meer negeren",
|
||||
"account.unfollow": "Ontvolgen",
|
||||
"account.unmute": "Negeer @{name} niet meer",
|
||||
"account.unmute": "@{name} niet meer negeren",
|
||||
"boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
|
||||
"column.blocks": "Geblokkeerde gebruikers",
|
||||
"column.community": "Lokale tijdlijn",
|
||||
@@ -43,8 +43,8 @@
|
||||
"confirmations.block.message": "Weet je zeker dat je {name} wilt blokkeren?",
|
||||
"confirmations.delete.confirm": "Verwijderen",
|
||||
"confirmations.delete.message": "Weet je zeker dat je deze toot wilt verwijderen?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.domain_block.confirm": "Negeer alles van deze server",
|
||||
"confirmations.domain_block.message": "Weet je het echt, echt zeker dat je alles van {domain} wil negeren? In de meeste gevallen is het blokkeren of negeren van een paar specifieke personen voldoende en gewenst.",
|
||||
"confirmations.mute.confirm": "Negeren",
|
||||
"confirmations.mute.message": "Weet je zeker dat je {name} wilt negeren?",
|
||||
"emoji_button.activity": "Activiteiten",
|
||||
@@ -148,7 +148,7 @@
|
||||
"status.load_more": "Meer laden",
|
||||
"status.media_hidden": "Media verborgen",
|
||||
"status.mention": "Vermeld @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.mute_conversation": "Negeer conversatie",
|
||||
"status.open": "Toot volledig tonen",
|
||||
"status.reblog": "Boost",
|
||||
"status.reblogged_by": "{name} boostte",
|
||||
@@ -159,7 +159,7 @@
|
||||
"status.sensitive_warning": "Gevoelige inhoud",
|
||||
"status.show_less": "Minder tonen",
|
||||
"status.show_more": "Meer tonen",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unmute_conversation": "Conversatie niet meer negeren",
|
||||
"tabs_bar.compose": "Schrijven",
|
||||
"tabs_bar.federated_timeline": "Globaal",
|
||||
"tabs_bar.home": "Jouw tijdlijn",
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"account.block": "Blokkér @{name}",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.block_domain": "Skjul alt fra {domain}",
|
||||
"account.disclaimer": "Denne brukeren er fra en annen instans. Dette tallet kan være høyere.",
|
||||
"account.edit_profile": "Rediger profil",
|
||||
"account.follow": "Følg",
|
||||
@@ -14,7 +14,7 @@
|
||||
"account.report": "Rapportér @{name}",
|
||||
"account.requested": "Venter på godkjennelse",
|
||||
"account.unblock": "Avblokker @{name}",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock_domain": "Vis {domain}",
|
||||
"account.unfollow": "Avfølg",
|
||||
"account.unmute": "Avdemp @{name}",
|
||||
"boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
|
||||
@@ -24,39 +24,39 @@
|
||||
"column.follow_requests": "Følgeforespørsler",
|
||||
"column.home": "Hjem",
|
||||
"column.mutes": "Dempede brukere",
|
||||
"column.notifications": "Varslinger",
|
||||
"column.notifications": "Varsler",
|
||||
"column.public": "Felles tidslinje",
|
||||
"column_back_button.label": "Tilbake",
|
||||
"column_subheading.navigation": "Navigation",
|
||||
"column_subheading.settings": "Settings",
|
||||
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
|
||||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"column_subheading.navigation": "Navigasjon",
|
||||
"column_subheading.settings": "Innstillinger",
|
||||
"compose_form.lock_disclaimer": "Din konto er ikke {locked}. Hvem som helst kan følge deg og se dine private poster.",
|
||||
"compose_form.lock_disclaimer.lock": "låst",
|
||||
"compose_form.placeholder": "Hva har du på hjertet?",
|
||||
"compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ike er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli fremhevd eller på annen måte bli synlig for uventede mottakere.",
|
||||
"compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ikke er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli fremhevd eller på annen måte bli synlig for uventede mottakere.",
|
||||
"compose_form.publish": "Tut",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "Merk media som følsomt",
|
||||
"compose_form.spoiler": "Skjul tekst bak advarsel",
|
||||
"compose_form.spoiler_placeholder": "Innholdsadvarsel",
|
||||
"confirmation_modal.cancel": "Cancel",
|
||||
"confirmations.block.confirm": "Block",
|
||||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||
"confirmations.delete.confirm": "Delete",
|
||||
"confirmations.delete.message": "Are you sure you want to delete this status?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.mute.confirm": "Mute",
|
||||
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
||||
"emoji_button.activity": "Activity",
|
||||
"emoji_button.flags": "Flags",
|
||||
"emoji_button.food": "Food & Drink",
|
||||
"confirmation_modal.cancel": "Avbryt",
|
||||
"confirmations.block.confirm": "Blokkèr",
|
||||
"confirmations.block.message": "Er du sikker på at du vil blokkere {name}?",
|
||||
"confirmations.delete.confirm": "Slett",
|
||||
"confirmations.delete.message": "Er du sikker på at du vil slette denne statusen?",
|
||||
"confirmations.domain_block.confirm": "Skjul alt fra domenet",
|
||||
"confirmations.domain_block.message": "Er du sikker på at du vil skjule hele domenet {domain}? I de fleste tilfeller er det bedre med målrettet blokkering eller demping.",
|
||||
"confirmations.mute.confirm": "Demp",
|
||||
"confirmations.mute.message": "Er du sikker på at du vil dempe {name}?",
|
||||
"emoji_button.activity": "Aktivitet",
|
||||
"emoji_button.flags": "Flagg",
|
||||
"emoji_button.food": "Mat og drikke",
|
||||
"emoji_button.label": "Sett inn emoji",
|
||||
"emoji_button.nature": "Nature",
|
||||
"emoji_button.objects": "Objects",
|
||||
"emoji_button.people": "People",
|
||||
"emoji_button.search": "Search...",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"emoji_button.nature": "Natur",
|
||||
"emoji_button.objects": "Objekter",
|
||||
"emoji_button.people": "Mennesker",
|
||||
"emoji_button.search": "Søk...",
|
||||
"emoji_button.symbols": "Symboler",
|
||||
"emoji_button.travel": "Reise & steder",
|
||||
"empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!",
|
||||
"empty_column.hashtag": "Det er ingenting i denne hashtagen ennå.",
|
||||
"empty_column.home": "Du har ikke fulgt noen ennå. Besøk {publlic} eller bruk søk for å komme i gang og møte andre brukere.",
|
||||
@@ -66,13 +66,13 @@
|
||||
"empty_column.public": "Det er ingenting her! Skriv noe offentlig, eller følg brukere manuelt fra andre instanser for å fylle den opp",
|
||||
"follow_request.authorize": "Autorisér",
|
||||
"follow_request.reject": "Avvis",
|
||||
"getting_started.appsshort": "Apps",
|
||||
"getting_started.appsshort": "Apper",
|
||||
"getting_started.faq": "FAQ",
|
||||
"getting_started.heading": "Kom i gang",
|
||||
"getting_started.open_source_notice": "Mastodon er fri programvare. Du kan bidra eller rapportere problemer på GitHub på {github}.",
|
||||
"getting_started.support": "{faq} • {userguide} • {apps}",
|
||||
"getting_started.userguide": "User Guide",
|
||||
"home.column_settings.advanced": "Advansert",
|
||||
"getting_started.userguide": "Brukerguide",
|
||||
"home.column_settings.advanced": "Avansert",
|
||||
"home.column_settings.basic": "Enkel",
|
||||
"home.column_settings.filter_regex": "Filtrér med regulære uttrykk",
|
||||
"home.column_settings.show_reblogs": "Vis fremhevinger",
|
||||
@@ -94,38 +94,38 @@
|
||||
"navigation_bar.public_timeline": "Felles tidslinje",
|
||||
"notification.favourite": "{name} likte din status",
|
||||
"notification.follow": "{name} fulgte deg",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
"notification.mention": "{name} nevnte deg",
|
||||
"notification.reblog": "{name} fremhevde din status",
|
||||
"notifications.clear": "Fjern varsler",
|
||||
"notifications.clear_confirmation": "Er du sikker på at du vil fjerne alle dine varsler?",
|
||||
"notifications.column_settings.alert": "Skrivebordsvarslinger",
|
||||
"notifications.column_settings.favourite": "Likt:",
|
||||
"notifications.column_settings.follow": "Nye følgere:",
|
||||
"notifications.column_settings.mention": "Nevninger:",
|
||||
"notifications.column_settings.reblog": "Fremhevinger:",
|
||||
"notifications.column_settings.mention": "Nevnt:",
|
||||
"notifications.column_settings.reblog": "Fremhevet:",
|
||||
"notifications.column_settings.show": "Vis i kolonne",
|
||||
"notifications.column_settings.sound": "Spill lyd",
|
||||
"notifications.settings": "Kolonneinstillinger",
|
||||
"onboarding.done": "Done",
|
||||
"onboarding.next": "Next",
|
||||
"onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
|
||||
"onboarding.page_four.home": "The home timeline shows posts from people you follow.",
|
||||
"onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
|
||||
"onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
|
||||
"onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
|
||||
"onboarding.page_one.welcome": "Welcome to Mastodon!",
|
||||
"onboarding.page_six.admin": "Your instance's admin is {admin}.",
|
||||
"onboarding.page_six.almost_done": "Almost done...",
|
||||
"onboarding.page_six.appetoot": "Bon Appetoot!",
|
||||
"onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
|
||||
"onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
|
||||
"onboarding.page_six.guidelines": "community guidelines",
|
||||
"onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
|
||||
"onboarding.page_six.various_app": "mobile apps",
|
||||
"onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
|
||||
"onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
|
||||
"onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
|
||||
"onboarding.skip": "Skip",
|
||||
"onboarding.done": "Ferdig",
|
||||
"onboarding.next": "Neste",
|
||||
"onboarding.page_five.public_timelines": "Den lokale tidslinjen viser offentlige poster fra alle på {domain}. Felles tidslinje viser offentlige poster fra alle som brukere på {domain} følger. Dette er de offentlige tidslinjene, et fint sted å oppdage nye brukere.",
|
||||
"onboarding.page_four.home": "Hjem er tidslinjen med alle brukere som du følger.",
|
||||
"onboarding.page_four.notifications": "Kolonnen med varsler viser når noen interakterer med deg.",
|
||||
"onboarding.page_one.federation": "Mastdodon er et nettverk med uavhengige servere som sammarbeider om å danne et stort sosialt nettverk. Vi kaller disse serverene instanser.",
|
||||
"onboarding.page_one.handle": "Du er på {domain}, så ditt fulle brukernavn er {handle}",
|
||||
"onboarding.page_one.welcome": "Velkommen til Mastodon!",
|
||||
"onboarding.page_six.admin": "Administratoren på din instans er {admin}.",
|
||||
"onboarding.page_six.almost_done": "Snart ferdig...",
|
||||
"onboarding.page_six.appetoot": "Bon Appetut!",
|
||||
"onboarding.page_six.apps_available": "Det er {apps} tilgjengelig for iOS, Android og andre plattformer.",
|
||||
"onboarding.page_six.github": "Mastodon er programvare med fri og åpen kildekode. Du kan rapportere feil, be om hjelp eller foreslå endringer på {github}.",
|
||||
"onboarding.page_six.guidelines": "samfunnets rettningslinjer",
|
||||
"onboarding.page_six.read_guidelines": "Vennligst les {guidelines} for {domain}!",
|
||||
"onboarding.page_six.various_app": "mobilapper",
|
||||
"onboarding.page_three.profile": "Rediger profilen din for å endre din avatar, biografi, og visningsnavn. Der finner du også andre innstillinger.",
|
||||
"onboarding.page_three.search": "Bruk søkemenyen for å søke etter emneknagger eller brukere, slik som {illustration} og {introductions}. For å søke på en bruker som ikke er på samme instans som deg bruk hele brukernavnet..",
|
||||
"onboarding.page_two.compose": "Skriv innlegg fra forfatt-kolonnen. Du kan laste opp bilder, justere synlighet, og legge til innholdsvarsler med knappene under.",
|
||||
"onboarding.skip": "Hopp over",
|
||||
"privacy.change": "Justér synlighet",
|
||||
"privacy.direct.long": "Post kun til nevnte brukere",
|
||||
"privacy.direct.short": "Direkte",
|
||||
@@ -142,24 +142,24 @@
|
||||
"report.target": "Rapporterer",
|
||||
"search.placeholder": "Søk",
|
||||
"search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
|
||||
"status.cannot_reblog": "This post cannot be boosted",
|
||||
"status.cannot_reblog": "Denne posten kan ikke fremheves",
|
||||
"status.delete": "Slett",
|
||||
"status.favourite": "Lik",
|
||||
"status.load_more": "Last mer",
|
||||
"status.media_hidden": "Media skjult",
|
||||
"status.mention": "Nevn @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.mute_conversation": "Demp samtale",
|
||||
"status.open": "Utvid denne statusen",
|
||||
"status.reblog": "Fremhev",
|
||||
"status.reblogged_by": "Fremhevd av {name}",
|
||||
"status.reply": "Svar",
|
||||
"status.replyAll": "Reply to thread",
|
||||
"status.replyAll": "Svar til samtale",
|
||||
"status.report": "Rapporter @{name}",
|
||||
"status.sensitive_toggle": "Klikk for å vise",
|
||||
"status.sensitive_warning": "Følsomt innhold",
|
||||
"status.show_less": "Vis mindre",
|
||||
"status.show_more": "Vis mer",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unmute_conversation": "Ikke demp samtale",
|
||||
"tabs_bar.compose": "Komponer",
|
||||
"tabs_bar.federated_timeline": "Felles",
|
||||
"tabs_bar.home": "Hjem",
|
||||
@@ -172,5 +172,5 @@
|
||||
"video_player.expand": "Utvid video",
|
||||
"video_player.toggle_sound": "Veksle lyd",
|
||||
"video_player.toggle_visible": "Veksle synlighet",
|
||||
"video_player.video_error": "Video could not be played"
|
||||
"video_player.video_error": "Video kunne ikke spilles av"
|
||||
}
|
||||
|
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"account.block": "Blocar",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.block": "Blocar @{name}",
|
||||
"account.block_domain": "Tot amagar del domeni {domain}",
|
||||
"account.disclaimer": "Aqueste compte es sus una autra instància. Los nombres pòdon èsser mai grandes.",
|
||||
"account.edit_profile": "Modificar lo perfil",
|
||||
"account.follow": "Sègre",
|
||||
"account.followers": "Abonats",
|
||||
"account.followers": "Seguidors",
|
||||
"account.follows": "Abonaments",
|
||||
"account.follows_you": "Vos sèc",
|
||||
"account.media": "Media",
|
||||
"account.mention": "Mencionar",
|
||||
"account.mute": "Rescondre",
|
||||
"account.media": "Mèdias",
|
||||
"account.mention": "Mencionar @{name}",
|
||||
"account.mute": "Rescondre @{name}",
|
||||
"account.posts": "Estatuts",
|
||||
"account.report": "Senhalar",
|
||||
"account.report": "Senhalar @{name}",
|
||||
"account.requested": "Invitacion mandada",
|
||||
"account.unblock": "Desblocar",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock": "Desblocar @{name}",
|
||||
"account.unblock_domain": "Desblocar {domain}",
|
||||
"account.unfollow": "Quitar de sègre",
|
||||
"account.unmute": "Quitar de rescondre",
|
||||
"boost_modal.combo": "Podètz butar {combo} per passar aquò lo còp que ven",
|
||||
"account.unmute": "Quitar de rescondre @{name}",
|
||||
"boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven",
|
||||
"column.blocks": "Personas blocadas",
|
||||
"column.community": "Fil public local",
|
||||
"column.community": "Flux d’actualitat public local",
|
||||
"column.favourites": "Favorits",
|
||||
"column.follow_requests": "Demandas d’abonament",
|
||||
"column.home": "Acuèlh",
|
||||
@@ -29,7 +29,7 @@
|
||||
"column_back_button.label": "Tornar",
|
||||
"column_subheading.navigation": "Navigacion",
|
||||
"column_subheading.settings": "Paramètres",
|
||||
"compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als abonats.",
|
||||
"compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
|
||||
"compose_form.lock_disclaimer.lock": "clavat",
|
||||
"compose_form.placeholder": "A de qué pensatz ?",
|
||||
"compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste{domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias a Mastodons. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
|
||||
@@ -40,13 +40,13 @@
|
||||
"compose_form.spoiler_placeholder": "Avertiment",
|
||||
"confirmation_modal.cancel": "Anullar",
|
||||
"confirmations.block.confirm": "Blocar",
|
||||
"confirmations.block.message": "Sètz segur de voler blocar {name}?",
|
||||
"confirmations.block.message": "Sètz segur de voler blocar {name} ?",
|
||||
"confirmations.delete.confirm": "Suprimir",
|
||||
"confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.domain_block.confirm": "Amagar tot lo domeni",
|
||||
"confirmations.domain_block.message": "Sètz segur segur de voler blocar complètament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
|
||||
"confirmations.mute.confirm": "Metre en silenci",
|
||||
"confirmations.mute.message": "Sètz segur de voler metre en silenci {name}?",
|
||||
"confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
|
||||
"emoji_button.activity": "Activitat",
|
||||
"emoji_button.flags": "Drapèus",
|
||||
"emoji_button.food": "Manjar e beure",
|
||||
@@ -57,13 +57,13 @@
|
||||
"emoji_button.search": "Cercar...",
|
||||
"emoji_button.symbols": "Simbòls",
|
||||
"emoji_button.travel": "Viatges & lòcs",
|
||||
"empty_column.community": "Lo fil public local es void. Escribètz quicòm per lo garnir !",
|
||||
"empty_column.community": "Lo flux public local es void. Escribètz quicòm per lo garnir !",
|
||||
"empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag",
|
||||
"empty_column.home": "Pel moment segètz pas segun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
|
||||
"empty_column.home.inactivity": "Vòstra pagina d’acuèlh es voida. Se sètz estat inactiu per un moment, serà tornada generar per vos dins una estona.",
|
||||
"empty_column.home.public_timeline": "lo fil public",
|
||||
"empty_column.home.public_timeline": "lo flux public",
|
||||
"empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.",
|
||||
"empty_column.public": "I a pas res aquí ! Escribètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo fil public.",
|
||||
"empty_column.public": "I a pas res aquí ! Escribètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
|
||||
"follow_request.authorize": "Autorizar",
|
||||
"follow_request.reject": "Regetar",
|
||||
"getting_started.appsshort": "Apps",
|
||||
@@ -83,16 +83,16 @@
|
||||
"media_gallery.toggle_visible": "Modificar la visibilitat",
|
||||
"missing_indicator.label": "Pas trobat",
|
||||
"navigation_bar.blocks": "Personas blocadas",
|
||||
"navigation_bar.community_timeline": "Fil public local",
|
||||
"navigation_bar.community_timeline": "Flux public local",
|
||||
"navigation_bar.edit_profile": "Modificar lo perfil",
|
||||
"navigation_bar.favourites": "Favorits",
|
||||
"navigation_bar.follow_requests": "Demandas d'abonament",
|
||||
"navigation_bar.info": "Mai informacions",
|
||||
"navigation_bar.logout": "Desconnexion",
|
||||
"navigation_bar.mutes": "Muted users",
|
||||
"navigation_bar.mutes": "Personas rescondudas",
|
||||
"navigation_bar.preferences": "Preferéncias",
|
||||
"navigation_bar.public_timeline": "Fil public global",
|
||||
"notification.favourite": "{name} a apondut a sos favorits :",
|
||||
"navigation_bar.public_timeline": "Flux public global",
|
||||
"notification.favourite": "{name} a ajustat a sos favorits :",
|
||||
"notification.follow": "{name} vos sèc.",
|
||||
"notification.mention": "{name} vos a mencionat :",
|
||||
"notification.reblog": "{name} a partejat vòstre estatut :",
|
||||
@@ -100,14 +100,14 @@
|
||||
"notifications.clear_confirmation": "Volètz vertadièrament levar totas vòstras las notificacions ?",
|
||||
"notifications.column_settings.alert": "Notificacions localas",
|
||||
"notifications.column_settings.favourite": "Favorits :",
|
||||
"notifications.column_settings.follow": "Nòus abonats :",
|
||||
"notifications.column_settings.follow": "Nòus seguidors :",
|
||||
"notifications.column_settings.mention": "Mencions :",
|
||||
"notifications.column_settings.reblog": "Partatges :",
|
||||
"notifications.column_settings.show": "Mostrar dins la colomna",
|
||||
"notifications.column_settings.sound": "Emetre un son",
|
||||
"notifications.settings": "Paramètres de la colomna",
|
||||
"onboarding.done": "Done",
|
||||
"onboarding.next": "Next",
|
||||
"onboarding.done": "Fach",
|
||||
"onboarding.next": "Seguent",
|
||||
"onboarding.page_five.public_timelines": "Lo flux local mòstra los estatuts publics del monde de vòstra intància, aquí {domain}. Lo flux federat mòstra los estatuts publics de tot lo mond sus {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.",
|
||||
"onboarding.page_four.home": "Lo flux d’acuèlh mòstra los estatuts del mond que seguètz.",
|
||||
"onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un enteragís amb vos",
|
||||
@@ -129,11 +129,11 @@
|
||||
"privacy.change": "Ajustar la confidencialitat del messatge",
|
||||
"privacy.direct.long": "Mostrar pas qu’a las personas mencionadas",
|
||||
"privacy.direct.short": "Dirècte",
|
||||
"privacy.private.long": "Mostrar pas qu’a vòstres abonats",
|
||||
"privacy.private.long": "Mostrar pas qu’a vòstres seguidors",
|
||||
"privacy.private.short": "Privat",
|
||||
"privacy.public.long": "Mostrar dins los fils publics",
|
||||
"privacy.public.long": "Mostrar dins los fluxes publics",
|
||||
"privacy.public.short": "Public",
|
||||
"privacy.unlisted.long": "Mostrar pas dins los fils publics",
|
||||
"privacy.unlisted.long": "Mostrar pas dins los fluxes publics",
|
||||
"privacy.unlisted.short": "Pas-listat",
|
||||
"reply_indicator.cancel": "Anullar",
|
||||
"report.heading": "Nòu senhalament",
|
||||
@@ -153,10 +153,10 @@
|
||||
"status.reblog": "Partejar",
|
||||
"status.reblogged_by": "{name} a partejat :",
|
||||
"status.reply": "Respondre",
|
||||
"status.replyAll": "Reply to thread",
|
||||
"status.replyAll": "Respondre a la conversacion",
|
||||
"status.report": "Senhalar @{name}",
|
||||
"status.sensitive_toggle": "Clicar per mostrar",
|
||||
"status.sensitive_warning": "Contengut embarrassant",
|
||||
"status.sensitive_warning": "Contengut sensible",
|
||||
"status.show_less": "Tornar plegar",
|
||||
"status.show_more": "Desplegar",
|
||||
"status.unmute_conversation": "Conversacions amb silenci levat",
|
||||
@@ -166,11 +166,11 @@
|
||||
"tabs_bar.local_timeline": "Flux public local",
|
||||
"tabs_bar.notifications": "Notificacions",
|
||||
"upload_area.title": "Lisatz e depausatz per mandar",
|
||||
"upload_button.label": "Apondre un mèdia",
|
||||
"upload_button.label": "Ajustar un mèdia",
|
||||
"upload_form.undo": "Anullar",
|
||||
"upload_progress.label": "Mandadís…",
|
||||
"video_player.expand": "Mostrar la vidèo",
|
||||
"video_player.toggle_sound": "Activar/Desactivar lo son",
|
||||
"video_player.toggle_visible": "Mostrar/Rescondre la vidèo",
|
||||
"video_player.video_error": "Video could not be played"
|
||||
"video_player.video_error": "Fracàs de la lectura de la vidèo"
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"account.block": "Blokuj @{name}",
|
||||
"account.block_domain": "Hide everything from {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.edit_profile": "Edytuj profil",
|
||||
"account.follow": "Obserwuj",
|
||||
@@ -14,7 +14,7 @@
|
||||
"account.report": "Zgłoś @{name}",
|
||||
"account.requested": "Oczekująca prośba",
|
||||
"account.unblock": "Odblokuj @{name}",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock_domain": "Odblokuj domenę {domain}",
|
||||
"account.unfollow": "Przestań obserwować",
|
||||
"account.unmute": "Cofnij wyciszenie @{name}",
|
||||
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
|
||||
@@ -43,8 +43,8 @@
|
||||
"confirmations.block.message": "Czy na pewno chcesz zablokować {name}?",
|
||||
"confirmations.delete.confirm": "Usuń",
|
||||
"confirmations.delete.message": "Czy na pewno chcesz usunąć ten status?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.domain_block.confirm": "Ukryj wszysyko z domeny",
|
||||
"confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.",
|
||||
"confirmations.mute.confirm": "Wycisz",
|
||||
"confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
|
||||
"emoji_button.activity": "Aktywność",
|
||||
@@ -60,18 +60,18 @@
|
||||
"empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
|
||||
"empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
|
||||
"empty_column.home": "Nie obserwujesz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć ciekawych ludzi.",
|
||||
"empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
|
||||
"empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
|
||||
"empty_column.home.public_timeline": "publiczna oś czasu",
|
||||
"empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.",
|
||||
"empty_column.public": "Tu nic nie ma! Napisz coś publicznie, lub dodaj ludzi z innych instancji, aby to wyświetlić.",
|
||||
"follow_request.authorize": "Autoryzuj",
|
||||
"follow_request.reject": "Odrzuć",
|
||||
"getting_started.appsshort": "Apps",
|
||||
"getting_started.appsshort": "Aplikacje",
|
||||
"getting_started.faq": "FAQ",
|
||||
"getting_started.heading": "Dowiedz się",
|
||||
"getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj {github}.",
|
||||
"getting_started.support": "{faq} • {userguide} • {apps}",
|
||||
"getting_started.userguide": "User Guide",
|
||||
"getting_started.userguide": "Podręcznik użytkownika",
|
||||
"home.column_settings.advanced": "Zaawansowane",
|
||||
"home.column_settings.basic": "Podstawowe",
|
||||
"home.column_settings.filter_regex": "Filtruj z użyciem wyrażeń regularnych",
|
||||
@@ -148,7 +148,7 @@
|
||||
"status.load_more": "Załaduj więcej",
|
||||
"status.media_hidden": "Zawartość multimedialna ukryta",
|
||||
"status.mention": "Wspomnij o @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.mute_conversation": "Wycisz konwersację",
|
||||
"status.open": "Rozszerz ten status",
|
||||
"status.reblog": "Podbij",
|
||||
"status.reblogged_by": "{name} podbił",
|
||||
@@ -159,7 +159,7 @@
|
||||
"status.sensitive_warning": "Wrażliwa zawartość",
|
||||
"status.show_less": "Pokaż mniej",
|
||||
"status.show_more": "Pokaż więcej",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unmute_conversation": "Cofnij wyciezenie konwersacji",
|
||||
"tabs_bar.compose": "Napisz",
|
||||
"tabs_bar.federated_timeline": "Globalne",
|
||||
"tabs_bar.home": "Strona główna",
|
||||
|
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"account.block": "Блокировать",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.block_domain": "Блокировать все с {domain}",
|
||||
"account.disclaimer": "Это пользователь с другого узла. Число может быть больше.",
|
||||
"account.edit_profile": "Изменить профиль",
|
||||
"account.follow": "Подписаться",
|
||||
"account.followers": "Подписаны",
|
||||
"account.follows": "Подписки",
|
||||
"account.follows_you": "Подписан(а) на Вас",
|
||||
"account.media": "Media",
|
||||
"account.media": "Медиаконтент",
|
||||
"account.mention": "Упомянуть",
|
||||
"account.mute": "Заглушить",
|
||||
"account.posts": "Посты",
|
||||
"account.report": "Пожаловаться",
|
||||
"account.requested": "Ожидает подтверждения",
|
||||
"account.unblock": "Разблокировать",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock_domain": "Разблокировать {domain}",
|
||||
"account.unfollow": "Отписаться",
|
||||
"account.unmute": "Снять глушение",
|
||||
"boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
|
||||
@@ -43,8 +43,8 @@
|
||||
"confirmations.block.message": "Вы уверены, что хотите заблокировать {name}?",
|
||||
"confirmations.delete.confirm": "Удалить",
|
||||
"confirmations.delete.message": "Вы уверены, что хотите удалить этот статус?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.domain_block.confirm": "Блокировать весь домен",
|
||||
"confirmations.domain_block.message": "Вы на самом деле уверены, что хотите блокировать весь {domain}? В большинстве случаев нескольких отдельных блокировок или глушений достаточно.",
|
||||
"confirmations.mute.confirm": "Заглушить",
|
||||
"confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?",
|
||||
"emoji_button.activity": "Занятия",
|
||||
@@ -148,7 +148,7 @@
|
||||
"status.load_more": "Показать еще",
|
||||
"status.media_hidden": "Медиаконтент скрыт",
|
||||
"status.mention": "Упомянуть @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.mute_conversation": "Заглушить тред",
|
||||
"status.open": "Развернуть статус",
|
||||
"status.reblog": "Продвинуть",
|
||||
"status.reblogged_by": "{name} продвинул(а)",
|
||||
@@ -159,7 +159,7 @@
|
||||
"status.sensitive_warning": "Чувствительный контент",
|
||||
"status.show_less": "Свернуть",
|
||||
"status.show_more": "Развернуть",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unmute_conversation": "Снять глушение с треда",
|
||||
"tabs_bar.compose": "Написать",
|
||||
"tabs_bar.federated_timeline": "Глобальная",
|
||||
"tabs_bar.home": "Главная",
|
||||
|
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"account.block": "Заблокувати",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.block_domain": "Заглушити {domain}",
|
||||
"account.disclaimer": "Це користувач з іншої інстанції. Число може бути більше.",
|
||||
"account.edit_profile": "Налаштування профілю",
|
||||
"account.follow": "Підписатися",
|
||||
"account.followers": "Підписники",
|
||||
"account.follows": "Підписки",
|
||||
"account.follows_you": "Підписаний(-а) на Вас",
|
||||
"account.media": "Media",
|
||||
"account.media": "Медія",
|
||||
"account.mention": "Згадати",
|
||||
"account.mute": "Заглушити",
|
||||
"account.posts": "Пости",
|
||||
"account.report": "Поскаржитися",
|
||||
"account.requested": "Очікує підтвердження",
|
||||
"account.unblock": "Розблокувати",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unblock_domain": "Розблокувати {domain}",
|
||||
"account.unfollow": "Відписатися",
|
||||
"account.unmute": "Зняти глушення",
|
||||
"boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
|
||||
@@ -28,8 +28,8 @@
|
||||
"column.public": "Глобальна стрічка",
|
||||
"column_back_button.label": "Назад",
|
||||
"column_subheading.navigation": "Навігація",
|
||||
"column_subheading.settings": "Settings",
|
||||
"compose_form.lock_disclaimer": "Ваш акаунт не {locked}. Любой может подписаться на вас и видеть ваши приватные посты.",
|
||||
"column_subheading.settings": "Налаштування",
|
||||
"compose_form.lock_disclaimer": "Ваш акаунт не {locked}. Кожен може підписатися на Вас та бачити Ваші приватні пости.",
|
||||
"compose_form.lock_disclaimer.lock": "приватний",
|
||||
"compose_form.placeholder": "Що у Вас на думці?",
|
||||
"compose_form.privacy_disclaimer": "Ваш приватний допис буде доставлено до згаданих користувачів на доменах {domains}. Ви довіряєте {domainsCount, plural, one {цьому серверу} other {цим серверам}}? Приватність постів працює тільки на інстанціях Mastodon. Якщо {domains} {domainsCount, plural, one {не є інстанцією Mastodon} other {не є інстанціями Mastodon}}, приватність поста не буде активована, та він може бути передмухнутий або іншим чином показаний не позначеним Вами користувачам.",
|
||||
@@ -43,9 +43,9 @@
|
||||
"confirmations.block.message": "Ви впевнені, що хочете заблокувати {name}?",
|
||||
"confirmations.delete.confirm": "Видалити",
|
||||
"confirmations.delete.message": "Ви впевнені, що хочете видалити цей допис?",
|
||||
"confirmations.domain_block.confirm": "Hide entire domain",
|
||||
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
|
||||
"confirmations.mute.confirm": "Заглушить",
|
||||
"confirmations.domain_block.confirm": "Сховати весь домен",
|
||||
"confirmations.domain_block.message": "Ви точно, точно впевнені, що хочете заблокувати весь домен {domain}? У більшості випадків для нормальної роботи краще заблокувати/заглушити лише деяких користувачів.",
|
||||
"confirmations.mute.confirm": "Заглушити",
|
||||
"confirmations.mute.message": "Ви впевнені, що хочете заглушити {name}?",
|
||||
"emoji_button.activity": "Заняття",
|
||||
"emoji_button.flags": "Прапори",
|
||||
@@ -66,7 +66,7 @@
|
||||
"empty_column.public": "Тут поки нічого немає! Опублікуйте щось, або вручну підпишіться на користувачів інших інстанцій, щоб заповнити стрічку.",
|
||||
"follow_request.authorize": "Авторизувати",
|
||||
"follow_request.reject": "Відмовити",
|
||||
"getting_started.appsshort": "Apps",
|
||||
"getting_started.appsshort": "Додатки",
|
||||
"getting_started.faq": "FAQ",
|
||||
"getting_started.heading": "Ласкаво просимо",
|
||||
"getting_started.open_source_notice": "Mastodon - програма з відкритим вихідним кодом. Ви можете допомогти проекту, або повідомити про проблеми на GitHub за адресою {github}.",
|
||||
@@ -148,7 +148,7 @@
|
||||
"status.load_more": "Завантажити більше",
|
||||
"status.media_hidden": "Медіаконтент приховано",
|
||||
"status.mention": "Згадати",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.mute_conversation": "Заглушити діалог",
|
||||
"status.open": "Розгорнути допис",
|
||||
"status.reblog": "Передмухнути",
|
||||
"status.reblogged_by": "{name} передмухнув(-ла)",
|
||||
@@ -159,7 +159,7 @@
|
||||
"status.sensitive_warning": "Непристойний зміст",
|
||||
"status.show_less": "Згорнути",
|
||||
"status.show_more": "Розгорнути",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unmute_conversation": "Зняти глушення з діалогу",
|
||||
"tabs_bar.compose": "Написати",
|
||||
"tabs_bar.federated_timeline": "Глобальна",
|
||||
"tabs_bar.home": "Головна",
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"account.block": "屏蔽 @{name}",
|
||||
"account.block_domain": "Hide everything from {domain}",
|
||||
"account.disclaimer": "由于这个账户处于另一个服务站上,实际数字会比这个更多。",
|
||||
"account.disclaimer": "由于这个账户处于另一个服务器实例上,实际数字会比这个更多。",
|
||||
"account.edit_profile": "修改个人资料",
|
||||
"account.follow": "关注",
|
||||
"account.followers": "关注者",
|
||||
@@ -12,7 +12,7 @@
|
||||
"account.mute": "将 @{name} 静音",
|
||||
"account.posts": "嘟文",
|
||||
"account.report": "举报 @{name}",
|
||||
"account.requested": "等候审批",
|
||||
"account.requested": "等待审批",
|
||||
"account.unblock": "解除对 @{name} 的屏蔽",
|
||||
"account.unblock_domain": "Unhide {domain}",
|
||||
"account.unfollow": "取消关注",
|
||||
@@ -32,7 +32,7 @@
|
||||
"compose_form.lock_disclaimer": "你的账户没 {locked}. 任何人可以通过关注你来查看只有关注者可见的嘟文.",
|
||||
"compose_form.lock_disclaimer.lock": "被保护",
|
||||
"compose_form.placeholder": "在想啥?",
|
||||
"compose_form.privacy_disclaimer": "你的私人嘟文,将被发送至你所提及的 {domains} 用户。你是否信任{domainsCount, plural, one {这个网站} other {这些网站}}?请留意,嘟文隐私设置只适用于各 Mastodon 服务站,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服务站} other {之中有些不是 Mastodon 服务站}},对方将无法收到这篇嘟文的隐私设置,然后可能被转嘟给不能预知的用户阅读。",
|
||||
"compose_form.privacy_disclaimer": "你的私人嘟文,将被发送至你所提及的 {domains} 用户。你是否信任{domainsCount, plural, one {这个网站} other {这些网站}}?请留意,嘟文隐私设置只适用于各 Mastodon 服务器实例,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服务器实例} other {之中有些不是 Mastodon 服务器实例}},对方将无法收到这篇嘟文的隐私设置,然后可能被转嘟给不能预知的用户阅读。",
|
||||
"compose_form.publish": "嘟嘟",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.sensitive": "将媒体文件标示为“敏感内容”",
|
||||
@@ -63,7 +63,7 @@
|
||||
"empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
|
||||
"empty_column.home.public_timeline": "公共时间轴",
|
||||
"empty_column.notifications": "你没有任何通知纪录,快向其他用户搭讪吧。",
|
||||
"empty_column.public": "跨站公共时间轴暂时没有内容!快写一些公共的嘟文,或者关注另一些服务站的用户吧!你和本站、友站的交流,将决定这里出现的内容。",
|
||||
"empty_column.public": "跨站公共时间轴暂时没有内容!快写一些公共的嘟文,或者关注另一些服务器实例的用户吧!你和本站、友站的交流,将决定这里出现的内容。",
|
||||
"follow_request.authorize": "批准",
|
||||
"follow_request.reject": "拒绝",
|
||||
"getting_started.appsshort": "Apps",
|
||||
@@ -92,29 +92,29 @@
|
||||
"navigation_bar.mutes": "被静音的用户",
|
||||
"navigation_bar.preferences": "首选项",
|
||||
"navigation_bar.public_timeline": "跨站公共时间轴",
|
||||
"notification.favourite": "{name} 赞你的嘟文",
|
||||
"notification.favourite": "{name} 赞了你的嘟文",
|
||||
"notification.follow": "{name} 开始关注你",
|
||||
"notification.mention": "{name} 提及你",
|
||||
"notification.reblog": "{name} 转嘟你的嘟文",
|
||||
"notification.reblog": "{name} 转嘟了你的嘟文",
|
||||
"notifications.clear": "清空通知纪录",
|
||||
"notifications.clear_confirmation": "你确定要清空通知纪录吗?",
|
||||
"notifications.column_settings.alert": "显示桌面通知",
|
||||
"notifications.column_settings.favourite": "赞你的嘟文:",
|
||||
"notifications.column_settings.favourite": "你的嘟文被赞:",
|
||||
"notifications.column_settings.follow": "关注你:",
|
||||
"notifications.column_settings.mention": "提及你:",
|
||||
"notifications.column_settings.reblog": "转你的嘟文:",
|
||||
"notifications.column_settings.reblog": "你的嘟文被转嘟:",
|
||||
"notifications.column_settings.show": "在通知栏显示",
|
||||
"notifications.column_settings.sound": "播放音效",
|
||||
"notifications.settings": "字段设置",
|
||||
"onboarding.done": "出发!",
|
||||
"onboarding.next": "下一步",
|
||||
"onboarding.page_five.public_timelines": "本站时间轴显示来自 {domain} 的所有人的公共嘟文。 跨站公共时间轴显示来自 {domain} 上的各位关注的所有人的公共嘟文。这些就是寻人好去处的公共时间轴啦。",
|
||||
"onboarding.page_five.public_timelines": "本站时间轴显示来自 {domain} 的所有人的公共嘟文。 跨站公共时间轴显示 {domain} 上的各位关注的来自所有Mastodon服务器实例上的人发表的公共嘟文。这些就是寻人好去处的公共时间轴啦。",
|
||||
"onboarding.page_four.home": "你的主时间轴上是你关注的用户的嘟文.",
|
||||
"onboarding.page_four.notifications": "如果你和他人产生了互动,便会出现在通知列上啦~",
|
||||
"onboarding.page_one.federation": "Mastodon 由一系列独立的服务器共同打造一个更大的网络,这就是我们的服务站啦。",
|
||||
"onboarding.page_one.federation": "Mastodon是由一系列独立的服务器共同打造的强大的社交网络,我们将这些独立但又相互连接的服务器叫做服务器实例。",
|
||||
"onboarding.page_one.handle": "你在 {domain}, {handle} 就是你的完整账户名称。",
|
||||
"onboarding.page_one.welcome": "欢迎来到 Mastodon!",
|
||||
"onboarding.page_six.admin": "{admin} 是你所在服务站的管理员.",
|
||||
"onboarding.page_six.admin": "{admin} 是你所在服务器实例的管理员.",
|
||||
"onboarding.page_six.almost_done": "快完成了...",
|
||||
"onboarding.page_six.appetoot": "嗷呜~",
|
||||
"onboarding.page_six.apps_available": "也有适用于 iOS, Android 和其它平台的 {apps} 咯~",
|
||||
@@ -122,9 +122,9 @@
|
||||
"onboarding.page_six.guidelines": "社区指南",
|
||||
"onboarding.page_six.read_guidelines": "别忘了看看 {domain} 的 {guidelines}!",
|
||||
"onboarding.page_six.various_app": "移动应用程序",
|
||||
"onboarding.page_three.profile": "编辑你的用户资料,像是头像、简介、和昵称等等。在那还可以找到其它首选项。",
|
||||
"onboarding.page_three.search": "用搜索来找人和标签吧,像是 {illustration} 或者 {introductions}。想找其它服务站上的人,用完整账户名称(用户名@域名)啦。",
|
||||
"onboarding.page_two.compose": "在这一列开始嘟!上面的按钮提供了上传图片,修改隐私设置和提示敏感内容等多种功能。.",
|
||||
"onboarding.page_three.profile": "修改你的个人资料,比如头像、简介、和昵称等等。在那还可以找到其它首选项。",
|
||||
"onboarding.page_three.search": "用搜索来找人和标签吧,比如 {illustration} 或者 {introductions}。想找其它服务器实例上的人,用完整账户名称(用户名@域名)啦。",
|
||||
"onboarding.page_two.compose": "从这里开始嘟!上面的按钮提供了上传图片,修改隐私设置和提示敏感内容等多种功能。.",
|
||||
"onboarding.skip": "好啦好啦我知道啦",
|
||||
"privacy.change": "调整隐私设置",
|
||||
"privacy.direct.long": "只有提及的用户能看到",
|
||||
|
@@ -1,5 +1,4 @@
|
||||
// allow override variables here
|
||||
require.context('../../assets/stylesheets/', false, /variables.*\.scss$/);
|
||||
const perf = require('./performance');
|
||||
|
||||
// import default stylesheet with variables
|
||||
require('font-awesome/css/font-awesome.css');
|
||||
@@ -14,21 +13,19 @@ function onDomContentLoaded(callback) {
|
||||
}
|
||||
|
||||
function main() {
|
||||
perf.start('main()');
|
||||
const Mastodon = require('mastodon/containers/mastodon').default;
|
||||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
window.Perf = require('react-addons-perf');
|
||||
|
||||
require.context('../images/', true);
|
||||
|
||||
// import customization styles
|
||||
require.context('../../assets/stylesheets/', false, /custom.*\.scss$/);
|
||||
|
||||
onDomContentLoaded(() => {
|
||||
const mountNode = document.getElementById('mastodon');
|
||||
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
||||
|
||||
ReactDOM.render(<Mastodon {...props} />, mountNode);
|
||||
perf.stop('main()');
|
||||
});
|
||||
}
|
||||
|
||||
|
31
app/javascript/mastodon/performance.js
Normal file
31
app/javascript/mastodon/performance.js
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Tools for performance debugging, only enabled in development mode.
|
||||
// Open up Chrome Dev Tools, then Timeline, then User Timing to see output.
|
||||
// Also see config/webpack/loaders/mark.js for the webpack loader marks.
|
||||
//
|
||||
|
||||
let marky;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
|
||||
// Increase Firefox's performance entry limit; otherwise it's capped to 150.
|
||||
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
|
||||
performance.setResourceTimingBufferSize(Infinity);
|
||||
}
|
||||
marky = require('marky');
|
||||
// allows us to easily do e.g. ReactPerf.printWasted() while debugging
|
||||
window.ReactPerf = require('react-addons-perf');
|
||||
window.ReactPerf.start();
|
||||
}
|
||||
|
||||
export function start(name) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
marky.mark(name);
|
||||
}
|
||||
}
|
||||
|
||||
export function stop(name) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
marky.stop(name);
|
||||
}
|
||||
}
|
@@ -91,19 +91,6 @@ const initialState = Immutable.Map({
|
||||
});
|
||||
|
||||
const normalizeStatus = (state, status) => {
|
||||
const replyToId = status.get('in_reply_to_id');
|
||||
const id = status.get('id');
|
||||
|
||||
if (replyToId) {
|
||||
if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
|
||||
state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
|
||||
}
|
||||
|
||||
if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
|
||||
state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -140,7 +127,7 @@ const appendNormalizedTimeline = (state, timeline, statuses, next) => {
|
||||
return state.updateIn([timeline, 'items'], Immutable.List(), list => list.concat(moreIds));
|
||||
};
|
||||
|
||||
const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
|
||||
const normalizeAccountTimeline = (state, accountId, statuses, replace, next) => {
|
||||
let ids = Immutable.List();
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
@@ -151,11 +138,11 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) =
|
||||
return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
|
||||
.set('isLoading', false)
|
||||
.set('loaded', true)
|
||||
.set('next', true)
|
||||
.set('next', next)
|
||||
.update('items', Immutable.List(), list => (replace ? ids : ids.concat(list))));
|
||||
};
|
||||
|
||||
const normalizeAccountMediaTimeline = (state, accountId, statuses, next) => {
|
||||
const normalizeAccountMediaTimeline = (state, accountId, statuses, replace, next) => {
|
||||
let ids = Immutable.List();
|
||||
|
||||
statuses.forEach((status, i) => {
|
||||
@@ -166,7 +153,7 @@ const normalizeAccountMediaTimeline = (state, accountId, statuses, next) => {
|
||||
return state.updateIn(['accounts_media_timelines', accountId], Immutable.Map(), map => map
|
||||
.set('isLoading', false)
|
||||
.set('next', next)
|
||||
.update('items', Immutable.List(), list => ids.concat(list)));
|
||||
.update('items', Immutable.List(), list => (replace ? ids : ids.concat(list))));
|
||||
};
|
||||
|
||||
const appendNormalizedAccountTimeline = (state, accountId, statuses, next) => {
|
||||
@@ -335,7 +322,7 @@ export default function timelines(state = initialState, action) {
|
||||
case ACCOUNT_TIMELINE_EXPAND_FAIL:
|
||||
return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
|
||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
||||
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
|
||||
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace, action.next);
|
||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
|
||||
case ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST:
|
||||
@@ -345,7 +332,7 @@ export default function timelines(state = initialState, action) {
|
||||
case ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL:
|
||||
return state.updateIn(['accounts_media_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
|
||||
case ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS:
|
||||
return normalizeAccountMediaTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
|
||||
return normalizeAccountMediaTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace, action.next);
|
||||
case ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS:
|
||||
return appendNormalizedAccountMediaTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
|
||||
case ACCOUNT_BLOCK_SUCCESS:
|
||||
|
@@ -1,9 +1,30 @@
|
||||
import main from '../mastodon/main';
|
||||
|
||||
if (!window.Intl || !Object.assign || !Number.isNaN ||
|
||||
!window.Symbol || !Array.prototype.includes) {
|
||||
// load polyfills dynamically
|
||||
import('../mastodon/polyfills').then(main).catch(e => {
|
||||
const needsBasePolyfills = !(
|
||||
window.Intl &&
|
||||
Object.assign &&
|
||||
Number.isNaN &&
|
||||
window.Symbol &&
|
||||
Array.prototype.includes
|
||||
);
|
||||
|
||||
const needsExtraPolyfills = !(
|
||||
window.IntersectionObserver &&
|
||||
window.requestIdleCallback
|
||||
);
|
||||
|
||||
// Latest version of Firefox and Safari do not have IntersectionObserver.
|
||||
// Edge does not have requestIdleCallback.
|
||||
// This avoids shipping them all the polyfills.
|
||||
if (needsBasePolyfills) {
|
||||
Promise.all([
|
||||
import(/* webpackChunkName: "base_polyfills" */ '../mastodon/base_polyfills'),
|
||||
import(/* webpackChunkName: "extra_polyfills" */ '../mastodon/extra_polyfills'),
|
||||
]).then(main).catch(e => {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
});
|
||||
} else if (needsExtraPolyfills) {
|
||||
import(/* webpackChunkName: "extra_polyfills" */ '../mastodon/extra_polyfills').then(main).catch(e => {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
});
|
||||
} else {
|
||||
|
@@ -1,107 +1,96 @@
|
||||
import emojify from 'mastodon/emoji';
|
||||
import { getLocale } from 'mastodon/locales';
|
||||
import { length } from 'stringz';
|
||||
import { default as dateFormat } from 'date-fns/format';
|
||||
import distanceInWordsStrict from 'date-fns/distance_in_words_strict';
|
||||
import IntlRelativeFormat from 'intl-relativeformat';
|
||||
import { delegate } from 'rails-ujs';
|
||||
|
||||
require.context('../images/', true);
|
||||
|
||||
const parseFormat = (format) => format.replace(/%(\w)/g, (_, modifier) => {
|
||||
switch (modifier) {
|
||||
case '%':
|
||||
return '%';
|
||||
case 'a':
|
||||
return 'ddd';
|
||||
case 'A':
|
||||
return 'ddd';
|
||||
case 'b':
|
||||
return 'MMM';
|
||||
case 'B':
|
||||
return 'MMMM';
|
||||
case 'd':
|
||||
return 'DD';
|
||||
case 'H':
|
||||
return 'HH';
|
||||
case 'I':
|
||||
return 'hh';
|
||||
case 'l':
|
||||
return 'H';
|
||||
case 'm':
|
||||
return 'M';
|
||||
case 'M':
|
||||
return 'mm';
|
||||
case 'p':
|
||||
return 'A';
|
||||
case 'S':
|
||||
return 'ss';
|
||||
case 'w':
|
||||
return 'd';
|
||||
case 'y':
|
||||
return 'YY';
|
||||
case 'Y':
|
||||
return 'YYYY';
|
||||
default:
|
||||
return `%${modifier}`;
|
||||
}
|
||||
});
|
||||
const { localeData } = getLocale();
|
||||
localeData.forEach(IntlRelativeFormat.__addLocaleData);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
function main() {
|
||||
const locale = document.documentElement.lang;
|
||||
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
const relativeFormat = new IntlRelativeFormat(locale);
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time[data-format]'), (content) => {
|
||||
const format = parseFormat(content.dataset.format);
|
||||
const formattedDate = dateFormat(content.getAttribute('datetime'), format);
|
||||
content.textContent = formattedDate;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
|
||||
const timeAgo = distanceInWordsStrict(new Date(), content.getAttribute('datetime'), {
|
||||
addSuffix: true,
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const formattedDate = dateTimeFormat.format(datetime);
|
||||
content.title = formattedDate;
|
||||
content.textContent = formattedDate;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
content.textContent = relativeFormat.format(datetime);;
|
||||
});
|
||||
content.textContent = timeAgo;
|
||||
});
|
||||
});
|
||||
|
||||
delegate(document, '.video-player video', 'click', ({ target }) => {
|
||||
if (target.paused) {
|
||||
target.play();
|
||||
} else {
|
||||
target.pause();
|
||||
}
|
||||
});
|
||||
delegate(document, '.video-player video', 'click', ({ target }) => {
|
||||
if (target.paused) {
|
||||
target.play();
|
||||
} else {
|
||||
target.pause();
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '.media-spoiler', 'click', ({ target }) => {
|
||||
target.style.display = 'none';
|
||||
});
|
||||
delegate(document, '.media-spoiler', 'click', ({ target }) => {
|
||||
target.style.display = 'none';
|
||||
});
|
||||
|
||||
delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
|
||||
if (button !== 0) {
|
||||
return true;
|
||||
}
|
||||
window.location.href = target.href;
|
||||
return false;
|
||||
});
|
||||
delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
|
||||
if (button !== 0) {
|
||||
return true;
|
||||
}
|
||||
window.location.href = target.href;
|
||||
return false;
|
||||
});
|
||||
|
||||
delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
|
||||
const contentEl = target.parentNode.parentNode.querySelector('.e-content');
|
||||
if (contentEl.style.display === 'block') {
|
||||
contentEl.style.display = 'none';
|
||||
target.parentNode.style.marginBottom = 0;
|
||||
} else {
|
||||
contentEl.style.display = 'block';
|
||||
target.parentNode.style.marginBottom = null;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
|
||||
const contentEl = target.parentNode.parentNode.querySelector('.e-content');
|
||||
if (contentEl.style.display === 'block') {
|
||||
contentEl.style.display = 'none';
|
||||
target.parentNode.style.marginBottom = 0;
|
||||
} else {
|
||||
contentEl.style.display = 'block';
|
||||
target.parentNode.style.marginBottom = null;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
delegate(document, '.account_display_name', 'input', ({ target }) => {
|
||||
const nameCounter = document.querySelector('.name-counter');
|
||||
nameCounter.textContent = 30 - length(target.value);
|
||||
});
|
||||
delegate(document, '.account_display_name', 'input', ({ target }) => {
|
||||
const nameCounter = document.querySelector('.name-counter');
|
||||
if (nameCounter) {
|
||||
nameCounter.textContent = 30 - length(target.value);
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '.account_note', 'input', ({ target }) => {
|
||||
const noteCounter = document.querySelector('.note-counter');
|
||||
noteCounter.textContent = 160 - length(target.value);
|
||||
});
|
||||
delegate(document, '.account_note', 'input', ({ target }) => {
|
||||
const noteCounter = document.querySelector('.note-counter');
|
||||
if (noteCounter) {
|
||||
noteCounter.textContent = 160 - length(target.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.Intl) {
|
||||
import(/* webpackChunkName: "base_polyfills" */ 'mastodon/base_polyfills').then(() => {
|
||||
main();
|
||||
}).catch(error => {
|
||||
console.log(error); // eslint-disable-line no-console
|
||||
});
|
||||
} else {
|
||||
main();
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
.card {
|
||||
background: $ui-base-color;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
padding: 60px 0;
|
||||
padding-bottom: 0;
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
@@ -245,4 +245,19 @@
|
||||
|
||||
.report-status__actions {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
|
||||
.nsfw-button {
|
||||
color: $white;
|
||||
font-size: 11px;
|
||||
width: 11px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trash-button {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
@@ -45,8 +45,13 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: none;
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-holder {
|
||||
|
@@ -60,6 +60,7 @@
|
||||
|
||||
.column-collapsable {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.column-collapsable__content {
|
||||
overflow: auto;
|
||||
@@ -554,13 +555,17 @@
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
cursor: default;
|
||||
|
||||
@keyframes fade {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
opacity: 1;
|
||||
animation: fade 150ms linear;
|
||||
|
||||
&.status-direct {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
|
||||
.status__display-name strong {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
.icon-button.disabled {
|
||||
color: lighten($ui-base-color, 16%);
|
||||
}
|
||||
@@ -604,6 +609,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.notification-favourite {
|
||||
.status.status-direct {
|
||||
background: transparent;
|
||||
|
||||
.icon-button.disabled {
|
||||
color: lighten($ui-base-color, 13%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__relative-time {
|
||||
color: lighten($ui-base-color, 26%);
|
||||
float: right;
|
||||
@@ -662,7 +677,7 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.status__action-bar-button-wrapper {
|
||||
.status__action-bar-button {
|
||||
float: left;
|
||||
margin-right: 18px;
|
||||
}
|
||||
@@ -1311,7 +1326,9 @@
|
||||
.drawer {
|
||||
flex: 1 1 100%;
|
||||
overflow: hidden;
|
||||
contain: strict;
|
||||
@supports(display: grid) { // hack to fix Chrome <57
|
||||
contain: strict;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 360px) {
|
||||
@@ -1486,7 +1503,9 @@
|
||||
flex: 1 1 auto;
|
||||
backface-visibility: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
contain: strict;
|
||||
@supports(display: grid) { // hack to fix Chrome <57
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
&.optionally-scrollable {
|
||||
overflow-y: auto;
|
||||
@@ -1778,6 +1797,7 @@
|
||||
|
||||
.getting-started__wrapper {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.getting-started__footer {
|
||||
@@ -1945,9 +1965,15 @@ button.icon-button.active i.fa-retweet {
|
||||
.load-more {
|
||||
display: block;
|
||||
color: lighten($ui-base-color, 26%);
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
font-size: inherit;
|
||||
text-align: center;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 15px;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
clear: both;
|
||||
|
||||
&:hover {
|
||||
background: lighten($ui-base-color, 2%);
|
||||
@@ -2233,7 +2259,9 @@ button.icon-button.active i.fa-retweet {
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
@supports(display: grid) { // hack to fix Chrome <57
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $ui-highlight-color;
|
||||
@@ -2893,11 +2921,15 @@ button.icon-button.active i.fa-retweet {
|
||||
min-width: 33px;
|
||||
}
|
||||
|
||||
a {
|
||||
.onboarding-modal__nav {
|
||||
color: darken($ui-secondary-color, 34%);
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
height: auto;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
@@ -2981,13 +3013,14 @@ button.icon-button.active i.fa-retweet {
|
||||
|
||||
.onboarding-modal__page-one {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.onboarding-modal__page-one__elephant-friend {
|
||||
background: url('../images/elephant-friend.png') no-repeat center center / contain;
|
||||
width: 147px;
|
||||
height: 160px;
|
||||
margin-right: 10px;
|
||||
background: url('../images/elephant-friend-1.png') no-repeat center center / contain;
|
||||
width: 155px;
|
||||
height: 193px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
@@ -3145,6 +3178,7 @@ button.icon-button.active i.fa-retweet {
|
||||
.boost-modal__action-bar,
|
||||
.confirmation-modal__action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: $ui-secondary-color;
|
||||
padding: 10px;
|
||||
line-height: 36px;
|
||||
@@ -3179,14 +3213,9 @@ button.icon-button.active i.fa-retweet {
|
||||
}
|
||||
|
||||
.confirmation-modal__action-bar {
|
||||
& > div {
|
||||
text-align: left;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
.confirmation-modal__cancel-button {
|
||||
background-color: transparent;
|
||||
color: darken($ui-secondary-color, 34%);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -3366,7 +3395,7 @@ button.icon-button.active i.fa-retweet {
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
top: 50%;
|
||||
transform: translateY(-35%);
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
@@ -66,7 +66,7 @@ body.rtl {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.status__action-bar-button-wrapper {
|
||||
.status__action-bar-button {
|
||||
float: right;
|
||||
margin-right: 0;
|
||||
margin-left: 18px;
|
||||
|
@@ -56,4 +56,8 @@ module Extractor
|
||||
tags.each { |tag| yield tag[:hashtag], tag[:indices].first, tag[:indices].last } if block_given?
|
||||
tags
|
||||
end
|
||||
|
||||
def extract_cashtags_with_indices(_text)
|
||||
[] # always returns empty array
|
||||
end
|
||||
end
|
||||
|
@@ -2,10 +2,8 @@
|
||||
|
||||
module Settings
|
||||
module Extend
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def settings
|
||||
ScopedSettings.for_thing(self)
|
||||
@settings ||= ScopedSettings.new(self)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@@ -1,14 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Settings
|
||||
class ScopedSettings < ::Setting
|
||||
def self.for_thing(object)
|
||||
class ScopedSettings
|
||||
def initialize(object)
|
||||
@object = object
|
||||
self
|
||||
end
|
||||
|
||||
def self.thing_scoped
|
||||
unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id)
|
||||
# rubocop:disable Style/MethodMissing
|
||||
def method_missing(method, *args)
|
||||
method_name = method.to_s
|
||||
# set a value for a variable
|
||||
if method_name[-1] == '='
|
||||
var_name = method_name.sub('=', '')
|
||||
value = args.first
|
||||
self[var_name] = value
|
||||
else
|
||||
# retrieve a value
|
||||
self[method_name]
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/MethodMissing
|
||||
|
||||
def respond_to_missing?(*)
|
||||
true
|
||||
end
|
||||
|
||||
def all_as_records
|
||||
vars = thing_scoped
|
||||
records = vars.map { |r| [r.var, r] }.to_h
|
||||
|
||||
Setting.default_settings.each do |key, default_value|
|
||||
next if records.key?(key) || default_value.is_a?(Hash)
|
||||
records[key] = Setting.new(var: key, value: default_value)
|
||||
end
|
||||
|
||||
records
|
||||
end
|
||||
|
||||
def []=(key, value)
|
||||
key = key.to_s
|
||||
record = thing_scoped.find_or_initialize_by(var: key)
|
||||
record.update!(value: value)
|
||||
|
||||
Rails.cache.write(Setting.cache_key(key, @object), value)
|
||||
value
|
||||
end
|
||||
|
||||
def [](key)
|
||||
Rails.cache.fetch(Setting.cache_key(key, @object)) do
|
||||
db_val = thing_scoped.find_by(var: key.to_s)
|
||||
if db_val
|
||||
default_value = Setting.default_settings[key]
|
||||
return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash)
|
||||
db_val.value
|
||||
else
|
||||
Setting.default_settings[key]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def thing_scoped
|
||||
Setting.unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@@ -89,6 +89,8 @@ class Account < ApplicationRecord
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :alphabetic, -> { order(domain: :asc, username: :asc) }
|
||||
scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
|
||||
scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
|
||||
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
|
||||
|
||||
delegate :email,
|
||||
:current_sign_in_ip,
|
||||
|
@@ -18,8 +18,6 @@ class AccountFilter
|
||||
private
|
||||
|
||||
def scope_for(key, value)
|
||||
accounts = Account.arel_table
|
||||
|
||||
case key.to_s
|
||||
when 'local'
|
||||
Account.local
|
||||
@@ -34,21 +32,26 @@ class AccountFilter
|
||||
when 'suspended'
|
||||
Account.suspended
|
||||
when 'username'
|
||||
Account.where(accounts[:username].matches("#{value}%"))
|
||||
Account.matches_username(value)
|
||||
when 'display_name'
|
||||
Account.where(accounts[:display_name].matches("#{value}%"))
|
||||
Account.matches_display_name(value)
|
||||
when 'email'
|
||||
users = User.arel_table
|
||||
Account.joins(:user).merge(User.where(users[:email].matches("#{value}%")))
|
||||
accounts_with_users.merge User.matches_email(value)
|
||||
when 'ip'
|
||||
return Account.default_scoped unless valid_ip?(value)
|
||||
matches_ip = User.where(current_sign_in_ip: value).or(User.where(last_sign_in_ip: value))
|
||||
Account.joins(:user).merge(matches_ip)
|
||||
if valid_ip?(value)
|
||||
accounts_with_users.merge User.with_recent_ip_address(value)
|
||||
else
|
||||
Account.default_scoped
|
||||
end
|
||||
else
|
||||
raise "Unknown filter: #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
def accounts_with_users
|
||||
Account.joins(:user)
|
||||
end
|
||||
|
||||
def valid_ip?(value)
|
||||
IPAddr.new(value)
|
||||
true
|
||||
|
@@ -50,6 +50,8 @@ module AccountInteractions
|
||||
# Mute relationships
|
||||
has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
|
||||
has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
|
||||
has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy
|
||||
has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
|
||||
has_many :conversation_mutes, dependent: :destroy
|
||||
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
|
||||
end
|
||||
|
@@ -23,4 +23,6 @@ class Follow < ApplicationRecord
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
end
|
||||
|
@@ -55,6 +55,7 @@ class MediaAttachment < ApplicationRecord
|
||||
validates :account, presence: true
|
||||
|
||||
scope :attached, -> { where.not(status_id: nil) }
|
||||
scope :unattached, -> { where(status_id: nil) }
|
||||
scope :local, -> { where(remote_url: '') }
|
||||
default_scope { order(id: :asc) }
|
||||
|
||||
|
@@ -49,7 +49,7 @@ class Notification < ApplicationRecord
|
||||
cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account
|
||||
|
||||
def activity(eager_loaded = true)
|
||||
eager_loaded ? send(activity_type.downcase) : super
|
||||
eager_loaded ? send(activity_type.underscore) : super()
|
||||
end
|
||||
|
||||
def type
|
||||
|
@@ -23,7 +23,7 @@ class Setting < RailsSettings::Base
|
||||
def [](key)
|
||||
return super(key) unless rails_initialized?
|
||||
|
||||
val = Rails.cache.fetch(cache_key(key, @object)) do
|
||||
val = Rails.cache.fetch(cache_key(key, nil)) do
|
||||
db_val = object(key)
|
||||
|
||||
if db_val
|
||||
@@ -35,7 +35,6 @@ class Setting < RailsSettings::Base
|
||||
default_settings[key]
|
||||
end
|
||||
end
|
||||
|
||||
val
|
||||
end
|
||||
|
||||
@@ -51,8 +50,6 @@ class Setting < RailsSettings::Base
|
||||
records
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_settings
|
||||
return {} unless RailsSettings::Default.enabled?
|
||||
RailsSettings::Default.instance
|
||||
|
@@ -55,8 +55,9 @@ class Status < ApplicationRecord
|
||||
validates_with StatusLengthValidator
|
||||
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
|
||||
|
||||
default_scope { order(id: :desc) }
|
||||
default_scope { recent }
|
||||
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :remote, -> { where.not(uri: nil) }
|
||||
scope :local, -> { where(uri: nil) }
|
||||
|
||||
|
@@ -25,6 +25,7 @@ class StreamEntry < ApplicationRecord
|
||||
STATUS_INCLUDES = [:account, :stream_entry, :conversation, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :conversation, :media_attachments, :tags, mentions: :account], thread: [:stream_entry, :account]].freeze
|
||||
|
||||
default_scope { where(activity_type: 'Status') }
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
|
||||
|
||||
delegate :target, :title, :content, :thread,
|
||||
|
@@ -35,6 +35,7 @@
|
||||
|
||||
class User < ApplicationRecord
|
||||
include Settings::Extend
|
||||
ACTIVE_DURATION = 14.days
|
||||
|
||||
devise :registerable, :recoverable,
|
||||
:rememberable, :trackable, :validatable, :confirmable,
|
||||
@@ -51,6 +52,9 @@ class User < ApplicationRecord
|
||||
scope :recent, -> { order(id: :desc) }
|
||||
scope :admins, -> { where(admin: true) }
|
||||
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
||||
scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
|
||||
scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
|
||||
scope :with_recent_ip_address, ->(value) { where(arel_table[:current_sign_in_ip].eq(value).or(arel_table[:last_sign_in_ip].eq(value))) }
|
||||
|
||||
before_validation :sanitize_languages
|
||||
|
||||
|
@@ -59,7 +59,13 @@ class AccountSearchService < BaseService
|
||||
end
|
||||
|
||||
def exact_match
|
||||
@_exact_match ||= Account.find_remote(query_username, query_domain)
|
||||
@_exact_match ||= begin
|
||||
if domain_is_local?
|
||||
Account.find_local(query_username)
|
||||
else
|
||||
Account.find_remote(query_username, query_domain)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def search_results
|
||||
|
@@ -2,6 +2,8 @@
|
||||
|
||||
module AuthorExtractor
|
||||
def author_from_xml(xml)
|
||||
return nil if xml.nil?
|
||||
|
||||
# Try <email> for acct
|
||||
acct = xml.at_xpath('./xmlns:author/xmlns:email', xmlns: TagManager::XMLNS)&.content
|
||||
|
||||
|
@@ -21,7 +21,7 @@ class NotifyService < BaseService
|
||||
end
|
||||
|
||||
def blocked_favourite?
|
||||
false
|
||||
@recipient.muting?(@notification.from_account)
|
||||
end
|
||||
|
||||
def blocked_follow?
|
||||
|
@@ -47,25 +47,30 @@ class ProcessFeedService < BaseService
|
||||
return
|
||||
end
|
||||
|
||||
status, just_created = nil
|
||||
|
||||
Rails.logger.debug "Creating remote status #{id}"
|
||||
status, just_created = status_from_xml(@xml)
|
||||
|
||||
return if status.nil?
|
||||
return status unless just_created
|
||||
ApplicationRecord.transaction do
|
||||
status, just_created = status_from_xml(@xml)
|
||||
|
||||
if verb == :share
|
||||
original_status = shared_status_from_xml(@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS))
|
||||
status.reblog = original_status
|
||||
return if status.nil?
|
||||
return status unless just_created
|
||||
|
||||
if original_status.nil?
|
||||
status.destroy
|
||||
return nil
|
||||
elsif original_status.reblog?
|
||||
status.reblog = original_status.reblog
|
||||
if verb == :share
|
||||
original_status = shared_status_from_xml(@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS))
|
||||
status.reblog = original_status
|
||||
|
||||
if original_status.nil?
|
||||
status.destroy
|
||||
return nil
|
||||
elsif original_status.reblog?
|
||||
status.reblog = original_status.reblog
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
status.save!
|
||||
status.save!
|
||||
end
|
||||
|
||||
notify_about_mentions!(status) unless status.reblog?
|
||||
notify_about_reblog!(status) if status.reblog? && status.reblog.account.local?
|
||||
@@ -184,7 +189,7 @@ class ProcessFeedService < BaseService
|
||||
def find_status(uri)
|
||||
if TagManager.instance.local_id?(uri)
|
||||
local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status')
|
||||
return Status.find(local_id)
|
||||
return Status.find_by(id: local_id)
|
||||
end
|
||||
|
||||
Status.find_by(uri: uri)
|
||||
|
@@ -108,12 +108,18 @@ class ProcessInteractionService < BaseService
|
||||
|
||||
def favourite!(xml, from_account)
|
||||
current_status = status(xml)
|
||||
|
||||
return if current_status.nil?
|
||||
|
||||
favourite = current_status.favourites.where(account: from_account).first_or_create!(account: from_account)
|
||||
NotifyService.new.call(current_status.account, favourite)
|
||||
end
|
||||
|
||||
def unfavourite!(xml, from_account)
|
||||
current_status = status(xml)
|
||||
|
||||
return if current_status.nil?
|
||||
|
||||
favourite = current_status.favourites.where(account: from_account).first
|
||||
favourite&.destroy
|
||||
end
|
||||
|
@@ -24,7 +24,10 @@
|
||||
.activity-stream.activity-stream-headless
|
||||
.entry= render partial: 'stream_entries/simple_status', locals: { status: status }
|
||||
.report-status__actions
|
||||
= link_to admin_report_reported_status_path(@report, status), method: :delete, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: t('admin.reports.delete') do
|
||||
- unless status.media_attachments.empty?
|
||||
= link_to admin_report_reported_status_path(@report, status, status: { sensitive: !status.sensitive }), method: :patch, class: 'nsfw-button', title: t("admin.reports.nsfw.#{!status.sensitive}") do
|
||||
= t("admin.reports.nsfw.#{!status.sensitive}")
|
||||
= link_to admin_report_reported_status_path(@report, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') } do
|
||||
= fa_icon 'trash'
|
||||
|
||||
- if !@report.action_taken?
|
||||
|
@@ -1,5 +1,7 @@
|
||||
object @media
|
||||
attribute :id, :type
|
||||
node(:url) { |media| full_asset_url(media.file.url(:original)) }
|
||||
|
||||
node(:url) { |media| full_asset_url(media.file.url(:original)) }
|
||||
node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
|
||||
node(:text_url) { |media| medium_url(media) }
|
||||
node(:text_url) { |media| medium_url(media) }
|
||||
node(:meta) { |media| media.file.meta }
|
||||
|
@@ -3,4 +3,4 @@ attributes :id, :remote_url, :type
|
||||
node(:url) { |media| full_asset_url(media.file.url(:original)) }
|
||||
node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
|
||||
node(:text_url) { |media| media.local? ? medium_url(media) : nil }
|
||||
node(:meta) { |media| media.file.meta }
|
||||
node(:meta) { |media| media.file.meta }
|
||||
|
@@ -1,4 +1,6 @@
|
||||
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility
|
||||
attributes :id, :created_at, :in_reply_to_id,
|
||||
:in_reply_to_account_id, :sensitive,
|
||||
:spoiler_text, :visibility, :language
|
||||
|
||||
node(:uri) { |status| TagManager.instance.uri_for(status) }
|
||||
node(:content) { |status| Formatter.instance.format(status) }
|
||||
|
@@ -18,8 +18,10 @@
|
||||
= ' - '
|
||||
= title
|
||||
|
||||
= stylesheet_pack_tag 'application', media: 'all'
|
||||
= stylesheet_pack_tag 'common', media: 'all'
|
||||
= stylesheet_pack_tag stylesheet_for_layout, media: 'all'
|
||||
= javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous'
|
||||
= javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
|
||||
= csrf_meta_tags
|
||||
|
||||
= yield :header_tags
|
||||
|
@@ -7,7 +7,7 @@
|
||||
- if !user_signed_in? && single_user_mode?
|
||||
%span.single-user-login
|
||||
= link_to t('auth.login'), new_user_session_path
|
||||
\u2014
|
||||
—
|
||||
%span.domain= link_to site_hostname, root_path
|
||||
%span.powered-by
|
||||
!= t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/tootsuite/mastodon'))
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user