Compare commits

...

88 Commits

Author SHA1 Message Date
Eugen Rochko
2d97c898f2 Bump version to 1.4.0.4 2017-05-26 14:16:04 +02:00
Akinori MUSHA
f6a93fc150 Go to root after login in single user mode (#3289)
In single user mode, visitors are redirected to the single user's
profile page.  So, if you are the owner without a session, you start
from that page, click the login button and authenticate yourself
expecting you'll soon get started with the home page, but in reality
you'll get redirected back to where you started from -- your own
profile page.

This fixes the behavior by redirecting you home after login if you
have started from your own profile page.
2017-05-26 14:14:03 +02:00
Akihiko Odaki
019f3377bb Spec Admin::BaseController (#3328) 2017-05-26 14:13:26 +02:00
Yamagishi Kazutoshi
4b11675bdc Change anchor to button element (#3321)
Fix warning for ESLint (jsx-a11y/href-no-hash).
2017-05-26 14:10:37 +02:00
Nolan Lawson
2531c5953b Replace onWheel listener with onScroll (#3324)
* Replace onWheel listener with onScroll

* Update column.js
2017-05-26 14:09:13 +02:00
unarist
c6db416ff7 Fix "Cannot read property" on missing status (#3322)
I've found this issue when I clicked replies to muted user on the timeline.

Properties I've removed in here were added with lazy loading using
IntersectionObserver (8e4d1cba), but those statuses are not need to be
tracked anyway because it will be rendered as only empty div.
2017-05-26 14:07:48 +02:00
Nolan Lawson
b00cb2aed3 Improve shouldComponentUpdate for status and status_action_bar (#3323) 2017-05-26 14:05:52 +02:00
Nolan Lawson
7c67cb5997 implement shouldComponentUpdate for relative_timestamp (#3320) 2017-05-26 03:25:41 +02:00
Eugen Rochko
a098d08d12 Fix style regression of buttons not inheriting document font by default (#3310) 2017-05-25 21:15:41 +02:00
Matt Jankowski
6267759607 Disable the Rubocop Style/GuardClause cop (#3316)
There are many spots throughout the codebase which are showing as covered by
specs in the simplecov output -- but which are not actually run, because they
are on the same line as a guard clause.

I plan on fixing some of these issues, but don't want to keep triggering this
rubocop style violation.

My preference would be that we use the PR review process to identify places
where a guard clause might be appropriate, but that we leave this cop turned off
by default.
2017-05-25 21:15:14 +02:00
Nolan Lawson
bc39ad37c4 Fix "contains" CSS for Chromium <57 (#3317)
Fixes #3277. I've tested in QupZilla on Mac and can confirm it fixes the issue, and that Chrome 58 is not affected (because it has Grid).
2017-05-25 21:14:56 +02:00
Nolan Lawson
a6ba004bf5 Attach ReactPerf to window for easier debugging (#3318) 2017-05-25 21:14:40 +02:00
unarist
b89ab7e69d Add flex: 0 0 auto to some components to avoid bugs on iOS9 (#3313) 2017-05-25 18:59:55 +02:00
Nolan Lawson
33d7338779 Fix Firefox issue with performance marks (#3315) 2017-05-25 18:59:18 +02:00
unarist
cf4fe6cab8 More use of next link header on account (media) timelines (#3311)
This will reduce requests on who have only few statuses.

- Use next link header to detect more items from first request
- Omit next link header if result items are fewer than requested count
(It had omit it only if result was empty before)
2017-05-25 17:09:13 +02:00
Ira
2241a15ee9 i18n: New strings translated to Hebrew (#3282) 2017-05-25 16:38:02 +02:00
Yamagishi Kazutoshi
bca334cd28 Remove arguments in association reader (#3305) 2017-05-25 16:28:14 +02:00
unarist
3e3ec9b2c8 Fix an error on dragging into status_content component (#3308) 2017-05-25 16:27:44 +02:00
Akihiko Odaki
a8736aab7a Cover TagsController more (#3306) 2017-05-25 16:07:38 +02:00
Nolan Lawson
71b266377c Remove unnecessary action bar wrapper divs (#3299) 2017-05-25 14:15:12 +02:00
alpaca-tc
08dce5e607 Refactor Setting (#3302)
* @object is not needed

* Remove unneeded dependencies

* Do not call private method

* Prefer #respond_to_missing? over #respond_to?

`#respond_to?` doesn't support `User.settings.method(:method_name)`

* Use find_or_initialize_by instead of
2017-05-25 14:14:36 +02:00
Nolan Lawson
2469fd1cdc Add improved performance marks for development mode (#3297) 2017-05-25 14:09:55 +02:00
Yamagishi Kazutoshi
531c1bb245 Add localization of datetime in public page (#3296) 2017-05-25 14:09:25 +02:00
Audun Larsen
58f5040ee8 Norwegian translation of the onboarding page (#3303)
* Fixes translation placeholder

* Translates onboarding page
2017-05-25 14:06:10 +02:00
Daigo 3 Dango
838f51770b Replace \u2014 with &mdash; (#3301) 2017-05-25 14:05:54 +02:00
Yamagishi Kazutoshi
c52090dbfe fix storybook (#3292) 2017-05-25 05:23:14 +02:00
unarist
807c192fcf Fix load more feature on the Account media gallery (#3293)
* Add load more button for large screens
* Fix `next` state value on the first loading
* Don't load if `isLoading || !hasMore`
* Start load on near the bottom
2017-05-25 05:22:46 +02:00
masarakki
3b59f9c6c2 fix-cache-returns-nil (#3213) 2017-05-25 05:18:08 +02:00
Nolan Lawson
135bdd149e Name the Webpack chunks (#3291) 2017-05-25 03:30:29 +02:00
Yamagishi Kazutoshi
3572138b16 Fix hovering default value for avatar component (#3290) 2017-05-25 03:08:05 +02:00
unarist
9f69aa3cb1 Prevent contents of the status placeholder from overflowing (#3287)
Since long lines may overflow and cause the status-list horizontally scrollable,
I added `overflow: hidden` to placeholder contents to prevent it.
2017-05-25 02:23:54 +02:00
Daigo 3 Dango
f5c3d20e9c Add ffmpeg and dependent packages as well as LD_LIBRARY_PATHs (#3276)
This change adds mp4 support confirmed on Heroku-16 stack.
2017-05-24 17:57:33 +02:00
Yamagishi Kazutoshi
1ec7c87001 Remove unnecessary constructors (#3280) 2017-05-24 17:55:16 +02:00
Sorin Davidoi
8e4d1cba00 Lazy load toots using IntersectionObserver (#3191)
* refactor(components/status_list): Lazy load using IntersectionObserver

* refactor(components/status_list): Avoid setState bottleneck

* refactor(components/status_list): Update state correctly

* fix(components/status): Render if isIntersecting is undefined

* refactor(components/status): Recycle timeout

* refactor(components/status): Reduce animation duration

* refactor(components/status): Use requestIdleCallback

* chore: Split polyfill bundles

* refactor(components/status_list): Increase rootMargin to 300%

* fix(components/status): Check if onRef is not defined

* chore: Add note about polyfill bundle splitting

* fix(components/status): Reduce animation duration to 0.3 seconds
2017-05-24 17:55:00 +02:00
Yamagishi Kazutoshi
676ba50601 Show error message to suspended user (#3281) 2017-05-24 16:39:09 +02:00
abcang
bbc3db8b20 Add test when hashtag and URL are concatenated (#3279) 2017-05-24 15:36:10 +02:00
Yamagishi Kazutoshi
f937cad68f Skip formatting for cashtag in status text (#3275)
Resolve #3270
2017-05-24 14:32:53 +02:00
Eugen Rochko
be83d450eb Fix DM being highlighted when it's inside a favourite notification (#3267) 2017-05-24 04:22:46 +02:00
Nolan Lawson
1fd18a61bd Use node instead of babel-node for streaming (#3269) 2017-05-24 00:45:05 +02:00
Akihiko Odaki
5d9f479538 Cover HomeController more (#3258) 2017-05-23 23:37:24 +02:00
unarist
3ce9ca4c99 Fix following/followers API to return correct link headers (#3268)
Link headers in following/followers API should include follow_id as max_id/since_id.

However, these API use current_user's account_id instead of follow_id from #3167.
This causes irrelevant result on loading more users.
2017-05-23 23:26:23 +02:00
Eugen Rochko
2ca1f0737a Fix Devise destroy method being available to delete user record (#3266)
(You may think that we need account deletions, but this way would've just orphaned the db records)
2017-05-23 21:32:42 +02:00
beatrix
19ecde8fe7 don't notify me when my toot is faved by someone i muted (#3245) 2017-05-23 20:05:52 +02:00
alpaca-tc
7ee5fc5d68 Toggle sensitive from admin page (#3261) 2017-05-23 19:45:43 +02:00
Matt Jankowski
4289ed1d13 Refactor of API timeline actions (#3263)
- Increase coverage to exercise all parts of each action
- Move into namespace to share common code
- Misc refactor of each action for smaller methods, simpler code
2017-05-23 18:11:39 +02:00
Daniel Hunsaker
256e3adc1d Add Support for Nanobox (#1709)
* Nanobox Support

- Added support for running Mastodon using Nanobox, both for local development, and for deployment to production
- Dev mode tested and is working properly
- Deployment is undergoing test as of this writing. If it works, this line will be amended to state success; if not, one or more subsequent commits will provide fixes.

* [nanobox] Resolve Deploy Issues

Everything seems to work except routing to the streaming API. Will investigate with the Nanobox staff and make fix commits if needed.

Changes made:
- Also need `NODE_ENV` in production
- Node runs on `:4000`
- Use `envsubst` to commit `.env.production` values, since `dotEnv` packages don't always support referencing other variables
- Can't precompile assets after `transform` hook, but do this locally so it only has to be done once.
- Rails won't create `production.log` on its own, so we do this ourselves.
- Some `start` commands run from `/data/` for some reason, so use absolute paths in command arguments

* [nanobox] Update Ruby version

* [nanobox] Fix db.rake Ruby code style issues

* [nanobox] Minor Fixes

Some minor adjustments to improve functionality:

- Fixed routing to `web.stream` instances
- Adjust `.env.nanobox` to properly generate a default `SMTP_FROM_ADDRESS` via `envsubst`
- Update Nginx configs to properly support the needed HTTP version and headers for proper functionality (the streaming API doesn't work without some of these settings in place)

* [nanobox] Move usage info to docs repo

* [nanobox] Updates for 1.2.x

- Need to leave out `pkg-config` since Nanobox deploys without Ruby's headers - create a gem group to exclude the gem during Nanobox installs, but allow it to remain part of the default set otherwise
- Update cron jobs to cover new/updated Rake tasks
- Update `.env.nanobox` to include latest defaults and additions

* [nanobox] Fix for nokogumbo, added in 1.3.x

Apparently, nokogumbo (pulled in by sanitize, added with `OEmbed Support for PreviewCard` (#2337) - 88725d6) tries to install before nokogiri, despite needing nokogiri available to build properly. Instruct it to use the same settings as nokogiri does when building nokogiri directly, instead of via bundler.

* [nanobox] Set NODE_ENV during asset compile

The switch to WebPack will rely on the local value of the NODE_ENV evar, so set it to production during asset compilation.

* [nanobox] Rebase on master; update Nginx configs

- `pkg-config` Gem no longer causes issues in Nanobox, so revert the Gemfile change which allowed excluding it
- Update Nginx configuration files with latest recommendations from production documentation
- Rebase on master to Get This Merged™

Everything should be golden!
2017-05-23 16:54:44 +02:00
Akihiko Odaki
152b4d54e8 Cover StatusesController more (#3259) 2017-05-23 16:34:28 +02:00
Akihiko Odaki
ea2ef16ea4 Cover StreamEntriesController more and remove redundant instructions (#3257)
* Cover StreamEntriesController more

* Remove redundant instructions in StreamEntriesController
2017-05-23 15:04:23 +02:00
Akihiko Odaki
1d3e0a5060 Spec AuthorizeFollowsController assigns @account (#3246)
@account is used by the view.
2017-05-23 13:12:45 +02:00
Akihiko Odaki
bf575a1f5e Introduce recent to Follow (#3247)
Introduce recent to Follow, as Account and other models have.
This change also adds specs for the scope and the dependents.
2017-05-23 13:12:19 +02:00
Yamagishi Kazutoshi
860ffc0560 Focus the submit button (#3253)
Focus the submit button when confirmation modal is opened.

Also, changed cancellation link to button.
This makes the meaning clearer.
2017-05-23 13:10:41 +02:00
Audun Larsen
7eb4abe20a Updates Norwegian translation (#3252) 2017-05-23 13:09:53 +02:00
Ira
1baa75f79f i18n: Latest Hebew strings translated (#3250) 2017-05-23 13:08:16 +02:00
Yamagishi Kazutoshi
1d436a4322 Refactoring. Removed useless import. (#3254)
original: https://github.com/tootsuite/mastodon/pull/2618
2017-05-23 13:08:01 +02:00
Akihiko Odaki
8fd174298d Cover AccountsController more in spec (#3229)
* Introduce recent scope to Status and StreamEntry

Introduce recent scope to Status and StreamEntry as Account has.

* Cover AccountsController more in AccountsController
2017-05-23 02:53:01 +02:00
Eugen Rochko
9afd7dadbf Fix more locale regressions from #3055 (#3242) 2017-05-23 02:50:04 +02:00
Matt Jankowski
8e84177305 Use local default for postgres host in node streaming service (#3240)
This location varies across postgres installations, and it seems like the pg
package knows how to guess correctly on each system.
2017-05-22 20:27:24 -04:00
Matt Jankowski
a28ce13b3e Coverage for api/web/settings controller (#3238) 2017-05-22 23:12:28 +02:00
Matt Jankowski
e1b42e9aa0 Add coverage for ReportFilter and AccountFilter (#3236) 2017-05-22 15:50:58 -04:00
Eugen Rochko
b51398d0dd Bump version 2017-05-22 21:38:19 +02:00
Matt Jankowski
ec34ec63b1 Specs for cleanup workers (#3235)
* Add spec files for feed and media cleanup workers

* Add coverage for feed and media cleanup schedulers

* Clean up feed and media cleanup workers
2017-05-22 19:36:21 +02:00
Eugen Rochko
4a4733b397 Similarly to #2426, put creation of remote statuses in a transaction, (#3233)
so that public timeline/caching would not encounter incomplete data
2017-05-22 19:35:48 +02:00
Akihiko Odaki
bda7391221 Cover ApplicationController more in spec (#3230) 2017-05-22 17:58:57 +02:00
Akihiko Odaki
b9e8ffbd12 Cover AccountUnfollowController more in spec (#3228) 2017-05-22 17:58:49 +02:00
Tomonori Murakami
7966d3a872 Fix locale bug when change it on preferences (#3223) (#3232) 2017-05-22 17:58:41 +02:00
Akihiko Odaki
422e4d897b Cover AccountFollowController more in spec (#3227) 2017-05-22 16:29:48 +02:00
Akihiko Odaki
cb2707776f Cover AboutController more in spec (#3226) 2017-05-22 16:27:19 +02:00
Eugen Rochko
48e7a22e34 Fix locale regression from #3055 (#3231) 2017-05-22 16:26:54 +02:00
Ratmir Karabut
2bb5486357 Update Russian translation (#3225)
* Add Russian translation (ru)

* Fix a missing comma

* Fix the wording for better consistency

* Update Russian translation

* Arrange Russian setting alphabetically

* Fix syntax error

* Update Russian translation

* Fix formatting error

* Update Russian translation

* Update Russian translation

* Update ru.jsx

* Fix syntax error

* Remove two_factor_auth.warning (appears obsolete)

* Add missing strings in ru.yml

A lot of new strings translated, especially for the newly added admin section

* Fix translation consistency

* Update Russian translation

* Update Russian translation (pluralizations)

* Update Russian translation

* Update Russian translation
2017-05-22 16:03:50 +02:00
Nolan Lawson
60e2b951de Add webpack-bundle-analyzer for production builds (#3170) 2017-05-22 15:42:11 +02:00
Immae
a94c152fd3 Allow alternate domains for mastodon handlers (#3187) 2017-05-22 15:40:04 +02:00
Nolan Lawson
9d04de1c8d Only load Intl data for current language (#3130)
* Only load Intl data for current language

* Extract common chunk only from application.js and public.js

* Generate locale packs, avoid caching on window object
2017-05-22 15:06:06 +02:00
Yamagishi Kazutoshi
73e4468ff3 Change "Account.any?" to "Account.exists?" (#3217) 2017-05-22 15:02:30 +02:00
m4sk1n
fbbd80b40b Updated Polish translation (#3204)
* i18n: updated Polish translation

Signed-off-by: Marcin Mikołajczak <m4sk1n@vivaldi.net>

* i18n: completed Polish translation

Signed-off-by: Marcin Mikołajczak <m4sk1n@vivaldi.net>

* i18n: corrected Polish translation

Signed-off-by: Marcin Mikołajczak <m4sk1n@vivaldi.net>

* i18n: Updated Polish translation

Signed-off-by: Marcin Mikołajczak <me@m4sk.in>

* Update simple_form.pl.yml

* Update simple_form.pl.yml
2017-05-22 15:01:53 +02:00
unarist
361a606edb Keep children of the column-collapsable until the transition is completed (#3218) 2017-05-22 15:01:27 +02:00
Clworld
df92f010ad Set config.cache_store in environments file. (#3219)
* Set config.cache_store in application.rb

* Set config.cache_store in environments.

* fix code format.
2017-05-22 15:01:02 +02:00
unarist
07af8c05fd Fix "Edit profile" on the account action bar (#3222) 2017-05-22 14:57:50 +02:00
Akihiko Odaki
aa662cecad single_user_mode? always returns boolean (#3215)
This change also adds a specification for the method.
2017-05-22 06:00:06 +02:00
Yamagishi Kazutoshi
84608c3ff8 Add translations for counter of profile (#3214)
ref #3101
2017-05-22 04:45:55 +02:00
Masoud Abkenar
b69365e397 Persian translation update (#3206)
* Persian translation update

* Persian translation update: new files

* Persian translation update

* activerecord.fa.yml language code

* Persian translation update

* fix indent
2017-05-22 00:20:13 +02:00
Eugen Rochko
a478af92c3 Fix forgotten to include language attribute in statuses JSON (#3209) 2017-05-21 21:38:09 +02:00
happycoloredbanana
7fba4cb3d1 Allow access token in URI (#3208) 2017-05-21 21:13:11 +02:00
ster
a4c757767f Update Ukrainian translation (#3203) 2017-05-21 18:52:50 +02:00
Akihiko Odaki
2af5cd96fe Fix mutes_controller error and incorrect statuses_controller report (#3202)
This commit fixes a regression in commit
f554807563.
2017-05-21 13:32:13 +02:00
Hayato IZUMI
860f408475 Update .gitignore (#3197)
* Remove duplicate 'node_modules'
* Replace 'public/packs'
2017-05-21 11:24:59 +02:00
vidarlee
440441ccb3 Enhance for the Chinese translation (#3198)
* Enhance for the Chinese translation

* Add filtered_languages for Chinese Translation include CN/HK/TW for PR #3175
2017-05-21 11:23:59 +02:00
Daigo 3 Dango
3eb13307ca Add a Japanese translation for PR #3175 (#3192) 2017-05-21 01:16:54 +02:00
Eugen Rochko
756db8103a Bump version to 1.4.0.2 (#3190) 2017-05-20 23:30:20 +02:00
174 changed files with 3111 additions and 821 deletions

109
.env.nanobox Normal file
View 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

View File

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

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

View File

@@ -74,6 +74,9 @@ Style/RegexpLiteral:
Style/Lambda:
Enabled: false
Style/GuardClause:
Enabled: false
Rails/HasAndBelongsToMany:
Enabled: false

View File

@@ -1,2 +1,5 @@
protobuf-compiler
libprotobuf-dev
ffmpeg
libxdamage1
libxfixes3

View File

@@ -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).recent.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.recent.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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,23 +51,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) {
@@ -85,7 +76,7 @@ class AutosuggestTextarea extends ImmutablePureComponent {
this.props.onChange(e);
}
onKeyDown (e) {
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
@@ -135,7 +126,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 +136,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,11 +149,11 @@ 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();

View File

@@ -19,7 +19,7 @@ class Avatar extends React.PureComponent {
};
state = {
hovering: true,
hovering: false,
};
handleMouseEnter = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,12 @@ class StatusList extends ImmutablePureComponent {
trackScroll: true,
};
state = {
isIntersecting: [{ }],
}
statusRefQueue = []
handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
@@ -43,6 +48,7 @@ class StatusList extends ImmutablePureComponent {
componentDidMount () {
this.attachScrollListener();
this.attachIntersectionObserver();
}
componentDidUpdate (prevProps) {
@@ -53,6 +59,39 @@ class StatusList extends ImmutablePureComponent {
componentWillUnmount () {
this.detachScrollListener();
this.detachIntersectionObserver();
}
attachIntersectionObserver () {
const onIntersection = (entries) => {
this.setState(state => {
const isIntersecting = { };
entries.forEach(entry => {
const statusId = entry.target.getAttribute('data-id');
state.isIntersecting[0][statusId] = entry.isIntersecting;
});
return { isIntersecting: [state.isIntersecting[0]] };
});
};
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 +106,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 +122,11 @@ class StatusList extends ImmutablePureComponent {
render () {
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
const isIntersecting = this.state.isIntersecting[0];
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 +145,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}

View File

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

View File

@@ -0,0 +1,2 @@
import 'intersection-observer';
import 'requestidlecallback';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"account.block": "مسدودسازی @{name}",
"account.block_domain": "Hide everything from {domain}",
"account.block_domain": "پنهان‌سازی همه چیز از سرور {domain}",
"account.disclaimer": "این کاربر عضو سرور متفاوتی است. شاید عدد واقعی بیشتر از این باشد.",
"account.edit_profile": "ویرایش نمایه",
"account.follow": "پی بگیرید",
@@ -14,7 +14,7 @@
"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": "با عبارت‌های باقاعده فیلتر کنید",
@@ -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، و سیستم‌های دیگر موجود است.",
@@ -124,16 +124,16 @@
"onboarding.page_six.various_app": "اپ‌های موبایل",
"onboarding.page_three.profile": "با ویرایش نمایه می‌توانید تصویر نمایه، نوشتهٔ معرفی، و نام نمایشی خود را تغییر دهید. ترجیحات دیگر شما هم آن‌جاست.",
"onboarding.page_three.search": "در نوار جستجو می‌توانید کاربران دیگر را بیابید یا هشتگ‌ها را ببینید، مانند {نقاشی} یا {معرفی}. برای یافتن افرادی که روی سرورهای دیگر هستند، شناسهٔ کامل آن‌ها را بنویسید.",
"onboarding.page_two.compose": "در ستون «نوشتن» می‌توانید نوشته‌های تازه بنویسید. همچنین با دکمه‌های زیر می‌توانید تصویر اضافه کنید، حریم خصوصی نوشته را تنظیم کنید، و هشدار محتوا بگذارید.",
"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,11 +144,11 @@
"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} بازبوقید",
@@ -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": "خانه",

View File

@@ -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": "פעילות",

View File

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

View File

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

View File

@@ -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": "Главная",

View File

@@ -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": "Головна",

View File

@@ -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": "只有提及的用户能看到",

View File

@@ -1,3 +1,5 @@
const perf = require('./performance');
// allow override variables here
require.context('../../assets/stylesheets/', false, /variables.*\.scss$/);
@@ -14,10 +16,10 @@ 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);
@@ -29,6 +31,7 @@ function main() {
const props = JSON.parse(mountNode.getAttribute('data-props'));
ReactDOM.render(<Mastodon {...props} />, mountNode);
perf.stop('main()');
});
}

View 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);
}
}

View File

@@ -140,7 +140,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 +151,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 +166,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 +335,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 +345,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:

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,10 @@ body {
}
}
button {
font-family: inherit;
}
button:focus {
outline: none;
}

View File

@@ -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 0.3s 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,
@@ -3145,6 +3177,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 +3212,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;

View File

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

View File

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

View File

@@ -2,10 +2,8 @@
module Settings
module Extend
extend ActiveSupport::Concern
def settings
ScopedSettings.for_thing(self)
@settings ||= ScopedSettings.new(self)
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ class NotifyService < BaseService
end
def blocked_favourite?
false
@recipient.muting?(@notification.from_account)
end
def blocked_follow?

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@
= stylesheet_pack_tag 'application', 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

View File

@@ -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
&mdash;
%span.domain= link_to site_hostname, root_path
%span.powered-by
!= t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/tootsuite/mastodon'))

View File

@@ -1,6 +1,5 @@
.landing-strip
= t('landing_strip_html',
name: content_tag(:span, display_name(account), class: :emojify),
link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
= t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path))
- if open_registrations?
= t('landing_strip_signup_html', sign_up_path: new_user_registration_path)

View File

@@ -31,7 +31,7 @@
.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 }
= link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener' do
%time{ datetime: status.created_at.iso8601, title: l(status.created_at), data: { format: t('time.formats.default') } }= l(status.created_at)
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
·
- if status.application
- if status.application.website.blank?

View File

@@ -0,0 +1,12 @@
<p dir="rtl">خوش آمدید <%= @resource.email %> !</p>
<p dir="rtl">شما الان در <%= @instance %> حساب باز کردید.</p>
<p dir="rtl">برای تأیید عضویت، لطفاً روی پیوند زیر کلیک کنید: <br>
<%= link_to 'تأیید حساب', confirmation_url(@resource, confirmation_token: @token) %>
<p dir="rtl">لطفاً همچنین <%= link_to 'شرایط و مقررات استفادهٔ', terms_url %> ما را هم بخوانید.</p>
<p dir="rtl">با احترام,<p>
<p dir="rtl">گردانندگان سرور <%= @instance %></p>

View File

@@ -0,0 +1,12 @@
خوش آمدید <%= @resource.email %> !
شما الان در <%= @instance %> حساب باز کردید.
برای تأیید عضویت، لطفاً روی پیوند زیر کلیک کنید:
<%= confirmation_url(@resource, confirmation_token: @token) %>
لطفاً همچنین شرایط و مقررات استفادهٔ ما را هم بخوانید <%= terms_url %>
با احترام،
گردانندگان سرور <%= @instance %>

View File

@@ -0,0 +1,3 @@
<p dir="rtl">سلام <%= @resource.email %>!</p>
<p dir="rtl">این پیغام برای این است که به شما بگوییم رمز شما در ماستدون تغییر کرده است.</p>

View File

@@ -0,0 +1,3 @@
سلام <%= @resource.email %>!
این پیغام برای این است که به شما بگوییم رمز شما در ماستدون تغییر کرده است.

View File

@@ -1,3 +1,3 @@
<p><%= @resource.email %>,嗨呀!</p>
<p>这只是一封用来通知你的密码已经改的邮件。_(:3」∠)_</p>
<p>这只是一封用来通知你的密码已经被修改的邮件。_(:3」∠)_</p>

View File

@@ -1,3 +1,3 @@
<%= @resource.email %>,嗨呀!
这只是一封用来通知你的密码已经改的邮件。_(:3」∠)_
这只是一封用来通知你的密码已经被修改的邮件。_(:3」∠)_

View File

@@ -0,0 +1,8 @@
<p dir="rtl">سلام <%= @resource.email %>!</p>
<p dir="rtl">یک نفر درخواست کرده تا رمز شما در ماستدون عوض شود. برای این کار پیوند زیر را به‌کار ببرید.</p>
<p dir="rtl"><%= link_to 'تغییر رمز', edit_password_url(@resource, reset_password_token: @token) %></p>
<p dir="rtl">اگر شما چنین درخواستی نداده‌اید، لطفاً این ایمیل را نادیده بگیرید.</p>
<p dir="rtl">تا وقتی که شما پیوند بالا را نبینید و رمز تازه‌ای نسازید، رمز شما عوض نخواهد شد.</p>

View File

@@ -0,0 +1,8 @@
سلام <%= @resource.email %>!
یک نفر درخواست کرده تا رمز شما در ماستدون عوض شود. برای این کار روی پیوند زیر کلیک کنید.
<%= edit_password_url(@resource, reset_password_token: @token) %>
اگر شما چنین درخواستی نداده‌اید، لطفاً این ایمیل را نادیده بگیرید.
تا وقتی که شما پیوند بالا را نبینید و رمز تازه‌ای نسازید، رمز شما عوض نخواهد شد.

View File

@@ -1,7 +1,7 @@
<p><%= @resource.email %> ,嗨呀!!</p>
<p>有人(但愿是你)请求更改你 Mastodon 账户的密码。如果是你的话,请点击下面的链接:</p>
<p>有人但愿是你请求更改你Mastodon账户的密码。如果是你的话请点击下面的链接</p>
<p><%= link_to '更改密码', edit_password_url(@resource, reset_password_token: @token) %></p>
<p>如果不是的话,忘了它吧。只有你通过上面的链接设置新的以后你的密码才会改变。</p>
<p>如果不是的话,忘了它吧。只有你本人通过上面的链接设置新的密码以后你的密码才会生效。</p>

View File

@@ -1,7 +1,7 @@
<%= @resource.email %> ,嗨呀!!
有人(但愿是你)请求更改你 Mastodon 账户的密码。如果是你的话,请点击下面的链接:
有人但愿是你请求更改你Mastodon账户的密码。如果是你的话请点击下面的链接
<%= link_to '更改密码', edit_password_url(@resource, reset_password_token: @token) %>
如果不是的话,忘了它吧。只有你通过上面的链接设置新的以后你的密码才会改变
如果不是的话,忘了它吧。只有你本人通过上面的链接设置新的密码以后你的密码才会生效

View File

@@ -17,7 +17,7 @@ class Scheduler::FeedCleanupScheduler
private
def inactive_users
User.confirmed.where('current_sign_in_at < ?', 14.days.ago)
User.confirmed.inactive
end
def redis

View File

@@ -12,6 +12,6 @@ class Scheduler::MediaCleanupScheduler
private
def unattached_media
MediaAttachment.reorder(nil).where(status_id: nil).where('created_at < ?', 1.day.ago)
MediaAttachment.reorder(nil).unattached.where('created_at < ?', 1.day.ago)
end
end

158
boxfile.yml Normal file
View File

@@ -0,0 +1,158 @@
run.config:
engine: ruby
engine.config:
runtime: ruby-2.4.1
extra_packages:
# basic servers:
- nginx
- nodejs
# for images:
- ImageMagick
# for videos:
- ffmpeg3
# to prep the .env file
- gettext-tools
cache_dirs:
- node_modules
extra_path_dirs:
- node_modules/.bin
build_triggers:
- .ruby-version
- Gemfile
- Gemfile.lock
- package.json
- yarn.lock
extra_steps:
- cp .env.nanobox .env
- gem install bundler
- bundle config build.nokogiri --with-iconv-dir=/data/ --with-zlib-dir=/data/
- bundle config build.nokogumbo --with-iconv-dir=/data/ --with-zlib-dir=/data/
- bundle install --clean
- yarn
fs_watch: true
deploy.config:
extra_steps:
- NODE_ENV=production bundle exec rake assets:precompile
- "[ -r /app/.env.production ] || sed 's/LOCAL_HTTPS=.*/LOCAL_HTTPS=true/i' /app/.env.nanobox > /app/.env.production"
transform:
- envsubst < /app/.env.production > /tmp/.env.production && mv /tmp/.env.production /app/.env.production
- |-
if [ -z "$LOCAL_DOMAIN" ]
then
. /app/.env.production
export LOCAL_DOMAIN
fi
erb /app/nanobox/nginx-web.conf.erb > /app/nanobox/nginx-web.conf
erb /app/nanobox/nginx-stream.conf.erb > /app/nanobox/nginx-stream.conf
- touch /app/log/production.log
before_live:
web.web:
- bundle exec rake db:migrate:setup
web.web:
start:
nginx: nginx -c /app/nanobox/nginx-web.conf
rails: bundle exec puma -C /app/config/puma.rb
routes:
- '/'
writable_dirs:
- tmp
log_watch:
rails: 'log/production.log'
network_dirs:
data.storage:
- public/system
web.stream:
start:
nginx: nginx -c /app/nanobox/nginx-stream.conf
node: yarn run start
routes:
- '/api/v1/streaming*'
# Somehow we're getting requests for scheme://domain//api/v1/streaming* - match those, too
- '//api/v1/streaming*'
writable_dirs:
- tmp
worker.sidekiq:
start: bundle exec sidekiq -c 5 -q default -q mailers -q pull -q push -L /app/log/sidekiq.log
writable_dirs:
- tmp
log_watch:
rails: 'log/production.log'
sidekiq: 'log/sidekiq.log'
network_dirs:
data.storage:
- public/system
cron:
- id: generate_static_gifs
schedule: '*/15 * * * *'
command: 'bundle exec rake mastodon:maintenance:add_static_avatars'
- id: update_counter_caches
schedule: '50 * * * *'
command: 'bundle exec rake mastodon:maintenance:update_counter_caches'
# runs feeds:clear, media:clear, users:clear, and push:refresh
- id: do_daily_tasks
schedule: '00 00 * * *'
command: 'bundle exec rake mastodon:daily'
- id: clear_silenced_media
schedule: '10 00 * * *'
command: 'bundle exec rake mastodon:media:remove_silenced'
- id: clear_remote_media
schedule: '20 00 * * *'
command: 'bundle exec rake mastodon:media:remove_remote'
- id: clear_unfollowed_subs
schedule: '30 00 * * *'
command: 'bundle exec rake mastodon:push:clear'
- id: send_digest_emails
schedule: '00 20 * * *'
command: 'bundle exec rake mastodon:emails:digest'
# The following two tasks can be uncommented to automatically open and close
# registrations on a schedule. The format of 'schedule' is a standard cron
# time expression: minute hour day month day-of-week; search for "cron
# time expressions" for more info on how to set these up. The examples here
# open registration only from 8 am to 4 pm, server time.
#
# - id: open_registrations
# schedule: '00 08 * * *'
# command: 'bundle exec rake mastodon:settings:open_registrations'
#
# - id: close_registrations
# schedule: '00 16 * * *'
# command: 'bundle exec rake mastodon:settings:close_registrations'
data.db:
image: nanobox/postgresql:9.5
data.redis:
image: nanobox/redis:3.0
data.storage:
image: nanobox/unfs:0.9

View File

@@ -13,6 +13,8 @@ require_relative '../lib/mastodon/version'
Dotenv::Railtie.load
require_relative '../lib/mastodon/redis_config'
module Mastodon
class Application < Rails::Application
# Settings in config/environments/* take precedence over those specified here.

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