Compare commits

...

163 Commits

Author SHA1 Message Date
kibigo!
585758a373 Themed prefetching 2017-11-16 21:37:08 -08:00
David Yip
b28b405b97 Merge remote-tracking branch 'origin/master' into gs-master 2017-11-16 22:52:37 -06:00
Yamagishi Kazutoshi
dc6e031364 Redirect to reports when moderator (#5733) 2017-11-17 03:26:34 +01:00
ysksn
9dd5e329ab Remove empty strings (#5732) 2017-11-17 10:52:30 +09:00
Eugen Rochko
3e90987c8b Fix some rubocop style issues (#5730) 2017-11-17 10:06:26 +09:00
Eugen Rochko
2151fd3150 Display moderator badge (green), admin badge changed to red (#5728) 2017-11-17 01:22:38 +01:00
Renato "Lond" Cerqueira
ad207456d6 Improve language filter (#5724)
* Scrub text of html before detecting language.

* Detect language on statuses coming from activitypub.

* Fix rubocop comments.

* Remove custom emoji from text before language detection
2017-11-16 13:51:38 +01:00
ysksn
9e3d24a150 Fix AccountFinderConcern::AccountFinder#with_usernames (#5722) 2017-11-16 12:13:03 +01:00
Surinna Curtis
ee560abdbe Merge pull request #216 from glitch-soc/merge-upstream-3023725
Merge upstream at commit 3023725
2017-11-16 01:38:26 -06:00
Surinna Curtis
35fbdc36f9 Merge tootsuite/master at 3023725936 2017-11-16 01:21:16 -06:00
ysksn
556c07df1f Add tests for Status#verb (#5717) 2017-11-16 15:46:29 +09:00
David Yip
88627fd7aa Merge pull request #217 from monsterpit-daggertooth/fix-streaming-host-env
Allow streaming server to take bindhost from HOST env
2017-11-15 23:40:07 -06:00
Daggertooth
7e17e764a5 Allow streaming server to take bindhost from HOST env 2017-11-15 21:20:35 -06:00
ysksn
3023725936 Add tests for Status#hidden? (#5719) 2017-11-16 11:07:27 +09:00
beatrix
04508868b0 Merge pull request #212 from aschmitz/feat/mute-reblogs
Allow hiding reblogs on a per-follow basis
2017-11-15 12:01:17 -05:00
Daniel Hunsaker
3e4b01b47d [Nanobox] Apply Release Notes Changes (#5670)
Apparently I missed some things in earlier commits/releases that needed to be applied to the Nanobox setup. All minor things, nothing that breaks anything, but still best to get them in place.

- Move cron jobs to their own component, so the Sidekiq component can be scaled up to multiple instances without causing issues with running the same cron job multiple times at once.
- Update cron jobs to the latest requirements, removing extraneous ones
- Add new variables to `.env.nanobox`
- Update Nginx to use correct cache header directives
2017-11-15 16:26:53 +01:00
ysksn
19e8b861a2 Delegate some methods of User to @settings (#5706)
* Move some tests of User into Settings::ScopedSettings

* Add a test for User@settings
2017-11-15 16:05:20 +01:00
ysksn
7d7df877ef Add a test for Tag#to_param (#5705) 2017-11-15 16:04:41 +01:00
masarakki
c73a1fb537 reusable-streaming (#5709) 2017-11-15 16:04:15 +01:00
mayaeh
f6bc6399e2 Add Japanese translation for DM block option. (#5704) 2017-11-15 03:57:09 +01:00
Surinna Curtis
031a5a8f92 Optional notification muting (#5087)
* Add a hide_notifications column to mutes

* Add muting_notifications? and a notifications argument to mute!

* block notifications in notify_service from hard muted accounts

* Add specs for how mute! interacts with muting_notifications?

* specs testing that hide_notifications in mutes actually hides notifications

* Add support for muting notifications in MuteService

* API support for muting notifications (and specs)

* Less gross passing of notifications flag

* Break out a separate mute modal with a hide-notifications checkbox.

* Convert profile header mute to use mute modal

* Satisfy eslint.

* specs for MuteService notifications params

* add trailing newlines to files for Pork :)

* Put the label for the hide notifications checkbox in a label element.

* Add a /api/v1/mutes/details route that just returns the array of mutes.

* Define a serializer for /api/v1/mutes/details

* Add more specs for the /api/v1/mutes/details endpoint

* Expose whether a mute hides notifications in the api/v1/relationships endpoint

* Show whether muted users' notifications are muted in account lists

* Allow modifying the hide_notifications of a mute with the /api/v1/accounts/:id/mute endpoint

* make the hide/unhide notifications buttons work

* satisfy eslint

* In probably dead code, replace a dispatch of muteAccount that was skipping the modal with launching the mute modal.

* fix a missing import

* add an explanatory comment to AccountInteractions

* Refactor handling of default params for muting to make code cleaner

* minor code style fixes oops

* Fixed a typo that was breaking the account mute API endpoint

* Apply white-space: nowrap to account relationships icons

* Fix code style issues

* Remove superfluous blank line

* Rename /api/v1/mutes/details -> /api/v2/mutes

* Don't serialize "account" in MuteSerializer

Doing so is somewhat unnecessary since it's always the current user's account.

* Fix wrong variable name in api/v2/mutes

* Use Toggle in place of checkbox in the mute modal.

* Make the Toggle in the mute modal look better

* Code style changes in specs and removed an extra space

* Code review suggestions from akihikodaki

Also fixed a syntax error in tests for AccountInteractions.

* Make AddHideNotificationsToMute Concurrent

It's not clear how much this will benefit instances in practice, as the
number of mutes tends to be pretty small, but this should prevent any
blocking migrations nonetheless.

* Fix up migration things

* Remove /api/v2/mutes
2017-11-15 03:56:41 +01:00
ysksn
6d7e05ec1f Add tests for StreamEntry (#5687)
* Add tests for StreamEntry

- `#object_type`
- `#verb`
- `#mentions`

* Fix to test results instead of implementations
2017-11-15 02:00:58 +01:00
ThibG
58bca7b1e4 Filter searched toots to be consistent with blocking behaviors (#5383) 2017-11-15 01:53:33 +01:00
ThibG
1c25853842 Use already-known remote user data if resolving temporarily fails in mentions (#5702) 2017-11-15 01:06:49 +01:00
Eugen Rochko
546257bc7f Allow specifying STATSD_NAMESPACE (#5700) 2017-11-15 07:22:43 +09:00
Eugen Rochko
fbef909c2a Add option to block direct messages from people you don't follow (#5669)
* Add option to block direct messages from people you don't follow

Fix #5326

* If the DM responds to a toot by recipient, allow it through

* i18n: Update Polish translation (for #5669) (#5673)
2017-11-14 21:12:57 +01:00
SerCom_KC
c3ec1e87b8 Updating Chinese (Simplified) translations (#5643)
* i18n: (zh-CN) Bug fix for note-counter.

* i18n: (zh-CN) Improve translations

* i18n: (zh-CN) Improve translations

* i18n: (zh-CN) Add missing translations

* i18n: (zh-CN) Improve translations

* i18n: (zh-CN) Add support.array key for better wording

* Revert "i18n: (zh-CN) Add support.array key for better wording"

This reverts commit 27bf9a946e886213e827cd985d4f62419db57534.
Looks like this commit can't get pass the checks, revert it for now.

* i18n: (zh-CN) Change `客户端` to `应用`

* i18n: (zh-CN) Improve translations

* i18n: (zh-CN) Add missing translations (#5635)

* i18n: (zh-CN) Change `两步验证` to `双重认证`

* i18n: (zh-CN) Improve translations
2017-11-14 20:44:42 +01:00
ysksn
48e27c47a7 Add a test for SiteUpload#cache_key (#5685) 2017-11-14 20:44:11 +01:00
Yamagishi Kazutoshi
1f1838420f Refactor remote_follow_spec.rb (#5690) 2017-11-14 20:41:17 +01:00
Yamagishi Kazutoshi
20150659e6 Add uniqueness to block email domains (#5692) 2017-11-14 20:37:17 +01:00
Marcin Mikołajczak
8087aa83d4 i18n: Update Polish translation (#5699)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-11-14 20:36:11 +01:00
mayaeh
249b0fe107 Add Japanese translations for moderator roles and its own admin actions. (#5689) 2017-11-14 15:53:14 +09:00
Marcin Mikołajczak
a6682a3000 i18n: fix typo in Polish translation (#5688) 2017-11-14 15:52:32 +09:00
ysksn
4112a0631f Add tests for Setting (#5683) 2017-11-14 11:08:04 +09:00
Anna e só
0e6c4cb796 l10n: PT-BR translation updated (#5681)
* Improved e-mail messages; delted repeated words

* pt-BR.json translations updated

* Revert "pt-BR.json translations updated"

This reverts commit 108c460531196fed6e6d14f93e8d8d047c835ffd.

* Updated pt-BR.json

* pt-BR.yml updated
2017-11-14 11:07:38 +09:00
David Yip
bfd9230d61 Merge pull request #214 from glitch-soc/keyword-mute
Maintain case-insensitivity when merging multiple matchers (#213)
2017-11-13 12:33:19 -06:00
David Yip
656d54e945 Maintain case-insensitivity when merging multiple matchers (#213)
When given two regexps, Regexp.union preserves the options set (or not
set) on each regex; this meant that none of the multiline (m),
case-insensitivity (i), or extended syntax (x) options were set.  Our
regexps are written expecting the m, i, and x options were set on all of
them, so we need to make sure that we preserve that behavior.
2017-11-13 11:06:02 -06:00
Eugen Rochko
92aaa55f06 Add code of conduct from GitHub generator (#5674) 2017-11-13 17:28:55 +01:00
takayamaki
5df8e30415 fix Code Climate badge on README.md (#5671)
and change badge's URI to https
2017-11-13 11:49:54 +09:00
ysksn
60f247c2e7 Add tests for SessionActivation (#5668)
* Fabricate SessionActivation

not only user_id but user association.

* Add tests for SessionActivation
2017-11-13 09:54:48 +09:00
Daniel Hunsaker
cf7e840990 Update model annotations to use BIGINT for IDs (#5461)
All the migrations have been updated to use BIGINTs for ID fields in the DB, but ActiveRecord needs to be told to treat those values as BIGINT as well. This PR does that.
2017-11-12 16:18:50 +01:00
KEINOS
252d0fe020 Fix #5652 - Notify too short when abbrev in JA (#5664)
* Fix #5652 - Notify too short when abbrev in JA

Fix #5652 of the notification message to be understandable when abbreviated.

* Changed quotes as original

Double quote to single and single quote as none. But I am not convinced of this fix.

* Added a single quote as YAMLlint's suggestion

* `bundle exec i18n-tasks normalize`
2017-11-12 16:51:47 +09:00
ysksn
2fb722397d Add tests for RemoteProfile (#5665) 2017-11-12 16:23:31 +09:00
aschmitz
9a42f7cbed Actually filter blocked reblogs from feed
And even a relevant test. Whoops.
2017-11-11 22:10:49 -06:00
Eugen Rochko
07f7192bc3 Fix #5632 - Reset column loading status after fetch fail (#5659) 2017-11-12 12:51:07 +09:00
aschmitz
48c705bbad Don't update follower counts on reblog toggle 2017-11-11 21:42:22 -06:00
Marcin Mikołajczak
fcb9533549 i18n: Update Polish translation (for #5635) (#5661)
* i18n: Update Polish translation (for #5635)

* 😑🔫
2017-11-11 22:31:20 +01:00
aschmitz
5128c4261e Updates per code review
Thanks, @valerauko!
2017-11-11 14:37:23 -06:00
Eugen Rochko
7bb8b0b2fc Add moderator role and add pundit policies for admin actions (#5635)
* Add moderator role and add pundit policies for admin actions

* Add rake task for turning user into mod and revoking it again

* Fix handling of unauthorized exception

* Deliver new report e-mails to staff, not just admins

* Add promote/demote to admin UI, hide some actions conditionally

* Fix unused i18n
2017-11-11 20:23:33 +01:00
ThibG
2b1190065c Retry thread resolving (#5599)
Thread resolving is one of the few tasks that isn't retried on failure.
One common cause for failure of this task is a well-connected user replying to
a toot from a little-connected user on a small instance: the small instance
will get many requests at once, and will often fail to answer requests within
the 10 seconds timeout used by Mastodon.

This changes makes the ThreadResolveWorker retry a few times, with a
rapidly-increasing time before retries and large random contribution in order
to spread the load over time.
2017-11-11 16:49:04 +01:00
aschmitz
b95c48748c Per-user reblog hiding implementation/fixes/tests
Note that this will only hide/show *future* reblogs by a user, and does
nothing to remove/add reblogs that are already in the timeline. I don't
think that's a particularly confusing behavior, and it's a lot easier
to implement (similar to mutes, I believe).
2017-11-10 22:04:54 -06:00
ysksn
56720ba590 Add tests for RemoteFollow (#5651)
* Add tests for RemoteFollow.initialize

* Add tests for RemoteFollow#valid?

* Add tests for RemoteFollow#subscribe_address_for
2017-11-10 16:56:02 +01:00
Matt
e5aa4128f6 Update en.yml (#5648)
Changed "Toots with replies" to read "Toots and replies"
2017-11-10 02:58:33 +01:00
Lynx Kotoura
f9e7336296 Fix focused background color of direct toots (#5642) 2017-11-09 19:40:34 +01:00
aschmitz
4944515020 "Show reblogs" per-follower UI/database changes
TODO:

* Tests (particularly for FollowRequests).
* Anything to respect the setting when putting reblogs in timelines.
2017-11-09 08:41:10 -06:00
ysksn
07cca6e364 Add tests for Notification (#5640)
* Add tests for Notification#target_status

* Add tests for Notification#browserable?

* Add tests for Notification.reload_stale_associations!
2017-11-09 14:37:10 +01:00
ysksn
54b42901df Add and Remove tests for FollowRequest (#5622)
* Add a test for FollowRequest#authorize!

* Remove tests

There is no need to test
ActiveModel::Validations::ClassMethods#validates.

* Make an alias of destroy! as reject!

Instead of defining the method,
make an alias of destroy! as reject! because of reducing test.
2017-11-09 14:36:52 +01:00
Nanamachi
d200e041fe Rewrite account_controller_spec (#5633)
* make accounts_controller_spec DRY

* Add blocked user spec
2017-11-09 14:36:17 +01:00
unarist
49a285ce15 Show confirmation dialog on leaving WebUI while composing (#5616)
* Show confirmation dialog on leaving WebUI while composing

Currently, Back button and Back hotkey can cause leaving from WebUI, as well as browser's back button. Users may hit those buttons accidentally, and their composing text will be lost.

So this prevents it by showing confirmation dialog from `onbeforeunload` event.

* Fix message and comments
2017-11-09 14:34:41 +01:00
Marcin Mikołajczak
cfd7b7a0b7 i18n: Update Polish translation (#5639)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-11-09 14:23:06 +01:00
nullkal
36376b5e23 Translate ja (#5637) 2017-11-09 14:22:55 +01:00
Quenty31
eb97bd8af6 i10n OC: Memorial (#5615) + #5467 (#5623)
* Changed ĩ => ï

* Changed ĩ => ï

* Add ability to disable login and mark accounts as memorial (#5615)
2017-11-08 15:19:49 +01:00
Naoki Kosaka
4c0a85ef9b In remove_remote, exclude removed media attachments. (#5626) 2017-11-08 15:19:22 +01:00
ysksn
64cc129225 Add tests for MediaAttachment (#5620)
- `#local?`
- `#needs_redownload?`
- `#to_param`
2017-11-08 15:29:07 +09:00
ysksn
97fc2da2e0 Add tests for CustomEmoji#local? and #object_type (#5621) 2017-11-08 15:28:17 +09:00
ThibG
889ada5ee2 Fix process mentions for local users, as local users are considered to use OStatus (#5618) 2017-11-07 22:15:15 +01:00
nullkal
3f16caaa50 Eliminate space around emoji (#5474)
* Eliminate space around emoji

* More improve emoji style

* Make more compatible with Twemoji

* Make scss-lint happy

* Make not modify normal emoji's behavior

* Decrease status__action-bar's margin-top to 5px

* Make the test be passed

* Revert "Make the test be passed"

This reverts commit 54a8c60e5907ef20a5ceb5ab2c86a933e06f3ece.

* Revert "Make not modify normal emoji's behavior"

This reverts commit 6a5bdf0c11df16ebd190cb3ab9d2e8f1349f435a.
2017-11-07 19:09:53 +01:00
ThibG
5d5c0f4f43 Twidere mention workaround (#5552)
* Work around Twidere and Tootdon bug

Tootdon and Twidere construct @user@domain handles from mentions in toots based
solely on the mention text and account URI's domain without performing any
webfinger call or retrieving account info from the Mastodon server.

As a result, when a remote user has WEB_DOMAIN ≠ LOCAL_DOMAIN, Twidere and
Tootdon will construct the mention as @user@WEB_DOMAIN. Now, this will usually
resolve to the correct account (since the recommended configuration is to have
WEB_DOMAIN perform webfinger redirections to LOCAL_DOMAIN) when processing
mentions, but won't do so when displaying them (as it does not go through the
whole account resolution at that time).

This change rewrites mentions to the resolved account, so that displaying the
mentions will work.

* Use lookbehind instead of non-capturing group in MENTION_RE

Indeed, substitutions with the previous regexp would erroneously eat any
preceding whitespace, which would lead to concatenated mentions in the
previous commit.

Note that users will “lose” up to one character space per mention for their
toots, as that regexp is also used to remove the domain-part of mentioned
users for character counting purposes, and it also erroneously removed the
preceding character if it was a space.
2017-11-07 19:08:14 +01:00
Eugen Rochko
1032f3994f Add ability to disable login and mark accounts as memorial (#5615)
Fix #5597
2017-11-07 19:06:44 +01:00
MitarashiDango
cbbeec05be Fix spell miss (SWIIFT_OBJECT_URL -> SWIFT_OBJECT_URL) (#5617) 2017-11-07 19:06:30 +01:00
voidSatisfaction
e618edf85a fix: slang to adequate word (#5453) 2017-11-07 14:49:50 +01:00
nullkal
b6e2e999bd Show the local couterpart of emoji when it exists in /admin/custom_emojis (#5467)
* Show the local couterpart of emoji when it exists in admin/custom_emojis

* Fix indentation

* Fix error

* Add class table-action-link to Overwrite link

* Make it enable to overwrite emojis

* Make Code Climate happy
2017-11-07 14:49:32 +01:00
MIYAGI Hikaru
782224c991 Avoid emojifying on invisible text (#5558) 2017-11-07 14:48:13 +01:00
ThibG
84cfee2488 Do not process undeliverable mentions (#5598)
* Resolve remote accounts when mentioned even if they are already known

This commit reduces the risk of not having up-to-date public key or protocol
information for a remote account, which is required to deliver toots
(especially direct messages).

* Do not add mentions in private messages for remote users we cannot deliver to

Mastodon does not deliver private and direct toots to OStatus users, as there
is no guarantee the remote software understands the toot's privacy. However,
users currently do not get any feedback on it (Mastodon won't attempt delivery,
but the toot will be displayed exactly the same way to the user).

This change introduces *some* feedback by not processing mentions that are
not going to be delivered. A long-term solution is still needed to have
delivery receipts or at least some better indication of what is going on, but
at least an user can see *something* is up.
2017-11-07 14:47:39 +01:00
ThibG
7bea1530f4 Resolve remote accounts when mentioned even if they are already known (#5539)
This commit reduces the risk of not having up-to-date public key or protocol
information for a remote account, which is required to deliver toots
(especially direct messages).
2017-11-07 14:31:57 +01:00
Yamagishi Kazutoshi
47b0c61853 Unify file upload to using fog (#5604) 2017-11-07 14:30:31 +01:00
nullkal
864c4d869f Make fullscreen video in detailed status plays in fullscreen (Partly Fix #5160) (#5611)
* Make fullscreen video in detailed status plays in fullscreen (Fix #5160)

* Directly assign the initial state
2017-11-07 14:24:55 +01:00
kedama
d8cd9000d9 Hide disabled custom emojis from emoji picker and emoji auto suggestions. (#5613)
Make the same behavior as /api/v1/custom_emojis.
2017-11-07 14:24:21 +01:00
David Yip
e1b7785788 Merge pull request #209 from glitch-soc/update-mastodongo
Mastodon GO! -> v0.1.1
2017-11-06 23:12:58 -06:00
kibigo!
bc8532359b Mastodon GO! -> v0.1.1 2017-11-06 20:00:03 -08:00
ysksn
d307ee79e9 Implement tests for Account#refresh! (#5601) 2017-11-06 13:54:41 +09:00
ysksn
cf01326cc1 Add test for Account#save_with_optional_media! (#5603)
There was a test when some of the properties are invalid, but none when all
of them are valid.
2017-11-06 13:54:12 +09:00
David Yip
a617060dfc Merge pull request #206 from glitch-soc/autocollapse-boost
Autocollapse boosts option
2017-11-05 21:02:37 -06:00
kibigo!
e0298d66f8 Autocollapse boosts option 2017-11-05 15:05:12 -08:00
Marcin Mikołajczak
d48779cf7b i18n: Improve Polish translation (#5596)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-11-06 07:06:54 +09:00
Yamagishi Kazutoshi
8a588145d5 Update extract-text-webpack-plugin to version 3.0.2 (#5584) 2017-11-05 13:07:59 +01:00
MIYAGI Hikaru
8abe9e9058 don't display any descendants of .invisible (#5567)
* don't display any descendants of .invisible

* reduce the scope of selector

* remove some rules for image

* lint
2017-11-05 13:05:50 +01:00
ysksn
15c0f6ae56 Implement tests for Account#possibly_stale? (#5591) 2017-11-05 17:20:05 +09:00
beatrix
73bf0ea7d1 Merge pull request #204 from glitch-soc/with-mastodon-go
Introducing: Mastodon GO!
2017-11-04 09:14:08 -04:00
kibigo!
276432790a Introducing: Mastodon GO! 2017-11-04 05:48:42 -07:00
beatrix
254b74c71f add memorial to production.rb
in memory of Natalie Nguyen

let her name ring through the ether
2017-11-03 12:34:50 -04:00
Quenty31
da3adc0a73 l10n Occitan (#5586)
* Update OC: time format

Correction for time format according to: https://opinion.jornalet.com/conselh-linguistic-de-jornalet/blog/2379/la-notacion-oraria-en-occitan
Harmonisation words in menu and confirmation windows

* Update for unlisted custum emoji + #5577

* correction subjonctiu

It's either siasque or siague

* Corrections

Any : qual que, in two words, else it means "some".
And "siasque" with S even if I don't pronounce it at all.

* Update oc.json
2017-11-03 17:42:30 +09:00
Yamagishi Kazutoshi
0338c16f9f Remove babel-plugin-react-transform (#5582) 2017-11-01 17:49:46 +01:00
MitarashiDango
38d072446b add account search condition (instance domain) (#5577) 2017-11-01 14:46:05 +01:00
Yamagishi Kazutoshi
8ae9bd0eea Upgrade compression-webpack-plugin to version 1.0.1 (#5581) 2017-11-01 14:42:19 +01:00
Sorin Davidoi
5521e94e24 refactor(features/ui): Avoid useless renders in WrappedSwitch (#5580) 2017-11-01 12:17:53 +01:00
Yamagishi Kazutoshi
763a2f8511 Replace react-router-scroll to react-router-scroll-4 (#5568) 2017-11-01 06:58:38 +09:00
Nolan Lawson
60f962eedc Refactor initial state: auto_play_gif (#5576) 2017-11-01 06:58:07 +09:00
K.SHIRAKASHI
47d56438da Revert ruby-jwt version (#5575)
jwt 2.1.0 still does not work well.
ref. https://github.com/zaru/webpush/issues/42
2017-11-01 00:47:35 +09:00
Nolan Lawson
0692991b54 Add ServiceWorker caching for static assets (#5524) 2017-10-31 12:25:51 +01:00
Yamagishi Kazutoshi
6705463ed0 Update dependencies for Node.js (2017-10-30) (#5565)
* Update autoprefixer to version 7.1.6

* Update babel-plugin-preval to version 1.6.1

* Update babel-plugin-transform-react-remove-prop-types to version 0.4.10

* Update babel-preset-env to version 1.6.1

* Update cross-env to version 5.1.1

* Update enzyme-adapter-react-16 to version 1.0.2

* Update eslint-plugin-import to version 2.8.0

* Update immutable to version 3.8.2

* Update express to 4.16.2

* Update intl-relativeformat to version 2.1.0

* Update postcss-loader to version 2.0.8

* Update react-immutable-pure-component to version 1.1.1

* Update react-motion to version 0.5.2

* Update react-notification to version 6.8.2

* Update react-overlays to version 0.8.3

* Update react-redux-loading-bar to version 2.9.3

* Update resolve-url-loader to version 2.2.0

* Update style-loader to version 0.19.0

* Update webpack to version 3.8.1

* Update webpack-dev-server to version 2.9.3

* yarn upgrade
2017-10-31 12:23:24 +01:00
Yamagishi Kazutoshi
a2a4bf4e78 Update dependencies for Ruby (2017-10-30) (#5566)
* Update better_errors to version 2.4.0

* Update binding_of_caller to version 0.7.3

* Update bootsnap to version 1.1.5

* Update browser to version 2.5.2

* Update capistrano to version 3.10.0

* Update capistrano-bundler to version 1.3.0

* Update capistrano-rbenv to version 2.1.2

* Update capybara to version 2.15.4

* Update cld3 to version 3.2.1

* Update fabrication to version 2.18.0

* Update fog-openstack to version 0.1.22

* Update kaminari to version 1.1.1

* Update lograge to version 0.7.1

* Update nokogiri to version 1.8.1

* Update oj to version 3.3.9

* Update ox to version 2.8.1

* Update parallel_tests to version 2.17.0

* Update pkg-config to version 1.2.8

* Update rspec-rails to version 3.7.1

* Update rubocop to version 0.51.0

* Update scss_lint to version 0.55.0

* Update sidekiq to version 5.0.5

* Update sidekiq-scheduler to version 2.1.10

* Update tzinfo-data to version 1.2017.3

* Update webpacker to version 3.0.2

* bundle update
2017-10-31 12:22:32 +01:00
Nolan Lawson
b254e6ca5f Refactor initial state: "me" (#5563)
* Refactor initial state: "me"

* remove "me" from reducers/meta.js
2017-10-31 11:27:48 +09:00
SerCom_KC
29609fbb6a Updating Chinese (Simplified) translations (#5508)
* i18n: (zh-CN) fix punctuations and spaces
Spaces are fixed according to https://github.com/sparanoid/chinese-copywriting-guidelines

* i18n: (zh-CN) fix punctuation

* i18n: (zh-CN) Adapt official translation of Discourse Privacy Policy from GitHub, with minor fixes
https://github.com/discourse/discourse/blob/master/config/locales/server.zh_CN.yml#L2677

* i18n: (zh-CN) Update missing translations

* i18n: (zh-CN) Fixing errors

* i18n: (zh-CN) Fix indent error

* i18n: (zh-CN) Fix language tag

* i18n: (zh-CN) Remove quotes

* i18n: (zh-CN) Update translation (#5485)

* i18n: (zh-CN) Remove whitespaces, x -> ×

* i18n: (zh-CN) Rewording on time distance

* i18n: (zh-CN) Overall improvements

* i18n: (zh-CN) i18n-tasks normalization

* i18n: (zh-CN) Add missing translation
2017-10-30 12:34:58 +09:00
ThibG
d37a56c07c Update remote ActivityPub users when fetching their toots (#5545) 2017-10-30 00:24:16 +09:00
Nolan Lawson
2cea4592a3 Avoid modifying emoji data inline (#5548) 2017-10-30 00:23:38 +09:00
Nolan Lawson
512feab222 Add margin to account for Edge disappearing scrollbar (#5522)
* Add margin to account for Edge disappearing scrollbar

* Fix 16px margin for DMs and horizontal line
2017-10-30 00:11:32 +09:00
Nolan Lawson
5e111ce16d Reactor unfollow_modal, boost_modal, delete_modal (#5505) 2017-10-30 00:10:15 +09:00
Alda Marteau-Hardi
4080569c2d Fix a grammatical error in the notifications. (#5555) 2017-10-29 02:08:37 +09:00
Marcin Mikołajczak
2cbb8e8cd1 i18n: Update Polish translation (#5547) 2017-10-28 12:43:20 +09:00
Herbert Kagumba
3e9236b343 Separate Follow/Unfollow and back buttons (#5496) 2017-10-27 19:14:11 +02:00
ThibG
89c77fe225 Instantiate service classes for each call (fixes #5540) (#5543) 2017-10-27 19:08:30 +02:00
Nolan Lawson
e843f62f47 Avoid unnecessary Motion components in icon_button.js (#5544) 2017-10-27 19:08:07 +02:00
Nolan Lawson
ec487166db Directly use <Motion/> if not reducing motion (#5546) 2017-10-27 19:06:54 +02:00
David Yip
37b267e2ab Add artist, title, and date metadata to boop.{mp3,ogg} (#5531)
For boop.mp3, this commit adds both ID3v1 and ID3v2 tags.  For boop.ogg,
we use Vorbis metadata.

In the case of boop.mp3, this also adds a cover image. Interestingly, it
didn't seem to affect the size of boop.mp3 much, despite being ~8k.
boop.ogg seemed to be much more affected and so no cover image was added
to that version.
2017-10-28 00:05:04 +09:00
Nolan Lawson
3de22a82bf Refactor initial state: reduce_motion and auto_play_gif (#5501) 2017-10-28 00:04:44 +09:00
Akihiko Odaki
e4080772b5 Use contenthash for ExtractTextWebpackPlugin (#5462)
[hash] is not documented.
2017-10-27 23:54:20 +09:00
David Yip
870d71b78b Merge branch 'master' into gs-master 2017-10-27 09:45:25 -05:00
nullkal
781105293c Feature: Unlisted custom emojis (#5485) 2017-10-27 16:11:30 +02:00
puckipedia
0cb329f63a Allow ActivityPub Note's tag and attachment to be single objects (#5534) 2017-10-27 16:10:36 +02:00
unarist
0129f5eada Optimize FixReblogsInFeeds migration (#5538)
We have changed how we store reblogs in the redis for bigint IDs. This process is done by 1) scan all entries in users feed, and 2) re-store reblogs by 3 write commands.

However, this operation is really slow for large instances. e.g. 1hrs on friends.nico (w/ 50k users). So I have tried below tweaks.

* It checked non-reblogs by `entry[0] == entry[1]`, but this condition won't work because `entry[0]` is String while `entry[1]` is Float. Changing `entry[0].to_i == entry[1]` seems work.
  -> about 4-20x faster (feed with less reblogs will be faster)
* Write operations can be batched by pipeline
  -> about 6x faster
* Wrap operation by Lua script and execute by EVALSHA command. This really reduces packets between Ruby and Redis.
  -> about 3x faster

I've taken Lua script way, though doing other optimizations may be enough.
2017-10-27 16:10:22 +02:00
Jenkins
656f5b6f87 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-10-26 15:08:34 +00:00
erin
22da775a85 Fix copying emojos: redirect to the page you were on (#5509) 2017-10-26 23:44:24 +09:00
David Yip
dd28b94cf0 Merge remote-tracking branch 'origin/master' into gs-master 2017-10-26 09:18:27 -05:00
りんすき
d556be2968 Fix column design broken with very long title (#5493)
* Fix #5314

* fix not beautiful code

* fix broken design with mobile view

* remove no longer needed code
2017-10-26 22:52:48 +09:00
unarist
4f337c020a Fix Cocaine::ExitStatusError when upload small non-animated GIF (#5489)
Looks like copied tempfile need to be flushed before further processing. This issue won't happen if the uploaded file has enough file size.
2017-10-26 22:48:35 +09:00
Nolan Lawson
02f7f3619a Remove translateZ(0) on modal overlay (#5478) 2017-10-26 22:46:50 +09:00
beatrix
a2612d0d38 Merge pull request #179 from glitch-soc/keyword-mute
Keyword muting
2017-10-25 17:37:48 -04:00
beatrix
31814ddda0 Merge pull request #198 from glitch-soc/gs-direct-timeline
Direct messages timeline from tootsuite/mastodon#4514
2017-10-25 17:37:06 -04:00
David Yip
42f2045c21 Merge remote-tracking branch 'STJrInuyasha/feature/direct-timeline' into gs-direct-timeline 2017-10-25 16:01:20 -05:00
Jenkins
5f0268ab84 Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master 2017-10-25 16:17:14 +00:00
Ratmir Karabut
20fee786b1 Update Russian translation (#5517)
* 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

* Update Russian translation (pin)

* Update Russian translation (account deletion)

* Fix extra line

* Update Russian translation (sessions)

* Update Russian translation

* Update Russian translation

* Fix merge conflicts (revert)

* Update Russian translation

* Update Russian translation (fix)

* Update Russian translation (fix quotes)

* Update Russian translation (fix quotes)

* Update Russian translation (fix)

* Update Russian translation

* Add quotes

* bundle exec i18n-tasks normalize
2017-10-26 00:21:58 +09:00
Anna e só
74777599cf l10n: PT-BR translation updated (#5530) 2017-10-25 23:11:03 +09:00
Olivier Nicole
1ba3725473 Complete Esperanto translation (#5520) 2017-10-25 22:38:37 +09:00
David Yip
e40fe4092d Remove nil check in Glitch::KeywordMute#=~.
@regex can no longer be nil, so we don't need to check it.
2017-10-24 19:03:59 -05:00
David Yip
d9485e6497 Assume Glitch::KeywordMute#destroy! works and error out if it doesn't.
There's nothing useful we can display if the destroy action messes up,
so might as well assert it does and complain loudly if it doesn't.
2017-10-24 18:56:57 -05:00
David Yip
d5c8ebe205 Use edit template for displaying errors in update. 2017-10-24 18:56:44 -05:00
David Yip
d03b48cea0 Also filter notifications containing muted keywords. 2017-10-24 18:51:27 -05:00
David Yip
9226257a1b Override Action View name inference in settings/keyword_mutes.
Glitch::KeywordMute's name is inferred as glitch_keyword_mutes, and in
templates this turns into e.g. settings/glitch/keyword_mutes.  Going
along with this convention means a lot of file movement, though, and for
a UI that's as temporary and awkward as this one I think it's less
effort to slap a bunch of as: options everywhere.

We'll do the Right Thing when we build out the API and frontend UI.
2017-10-24 18:40:28 -05:00
David Yip
641f90e73a Fix example description.
This example actually checks matches at the end of a string.
2017-10-24 18:33:02 -05:00
David Yip
f5a3283976 Switch to Regexp.union for building the mute expression.
Also make the keyword-building methods private: they always probably
should have been private, but now I have encoded enough fun and games
into them that it now seems wrong for them to *not* be private.
2017-10-24 18:31:34 -05:00
Ondřej Hruška
516eeeb43d option to add title to <Button>, use for toot buttons (#197) 2017-10-24 19:08:07 +02:00
David Yip
8410d33b49 Only cache the regex text, not the regex itself.
It is possible to cache a Regexp object, but I'm not sure what happens
if e.g. that object remains in cache across two different Ruby versions.
Caching a string seems to raise fewer questions.
2017-10-23 19:31:59 -05:00
Matthew Walsh
a76b024228 Changes to match other timelines in 2.0 2017-10-22 18:45:35 -07:00
Matthew Walsh
3db80f75a6 Added a timeline for Direct statuses
* Lists all Direct statuses you've sent and received
* Displayed in Getting Started
* Streaming server support for direct TL
2017-10-22 18:35:14 -07:00
David Yip
af8f06413e KeywordMute matcher: more closely mimic Regexp#=~ behavior.
Regexp#=~ returns nil if it does not match.  An empty mute set does not
match any status, so KeywordMute::Matcher#=~ ought to return nil also.
2017-10-22 01:12:21 -05:00
David Yip
1a60445a5f Address unused translation errors. 2017-10-22 01:05:56 -05:00
David Yip
4c84513e04 Use current_account from ApplicationController.
This avoids copy-pasting definitions of set_account.
2017-10-22 01:02:52 -05:00
David Yip
4b68e82a19 Don't add \b to whole-word keywords that don't start with word characters.
Ditto for ending with \b.

Consider muting the phrase "(hot take)".  I stipulate it is reasonable
to enter this with the default "match whole word" behavior.  Under the
old behavior, this would be encoded as

    \b\(hot\ take\)\b

However, if \b is before the first character in the string and the first
character in the string is not a word character, then the match will
fail.  Ditto for after.  In our example, "(" is not a word character, so
this will not match statuses containing "(hot take)", and that's a very
surprising behavior.

To address this, we only add leading and trailing \b to keywords that
start or end with word characters.
2017-10-22 00:38:54 -05:00
David Yip
19826774f0 keyword mutes: also check spoiler (CW) text and reblogged statuses. 2017-10-22 00:38:53 -05:00
David Yip
ad86c86fa8 Apply keyword mutes to reblogs. 2017-10-21 15:44:47 -05:00
David Yip
670e6a33f8 Move KeywordMute into Glitch namespace.
There are two motivations for this:

1. It looks like we're going to add other features that require
   server-side storage (e.g. user notes).

2. Namespacing glitchsoc modifications is a good idea anyway: even if we
   do not end up doing (1), if upstream introduces a keyword-mute feature
   that also uses a "KeywordMute" model, we can avoid some merge
   conflicts this way and work on the more interesting task of
   choosing which implementation to use.
2017-10-21 14:54:36 -05:00
David Yip
cd04e3df58 Fill in create, edit, update, and destroy for keyword mutes interface.
Also add a destroy-all action, which can be useful if you're flushing an
old list entirely to start a new one.
2017-10-21 14:54:36 -05:00
David Yip
4a64181461 Allow keywords to match either substrings or whole words.
Word-boundary matching only works as intended in English and languages
that use similar word-breaking characters; it doesn't work so well in
(say) Japanese, Chinese, or Thai.  It's unacceptable to have a feature
that doesn't work as intended for some languages.  (Moreso especially
considering that it's likely that the largest contingent on the Mastodon
bit of the fediverse speaks Japanese.)

There are rules specified in Unicode TR29[1] for word-breaking across
all languages supported by Unicode, but the rules deliberately do not
cover all cases.  In fact, TR29 states

    For example, reliable detection of word boundaries in languages such
    as Thai, Lao, Chinese, or Japanese requires the use of dictionary
    lookup, analogous to English hyphenation.

So we aren't going to be able to make word detection work with regexes
within Mastodon (or glitchsoc).  However, for a first pass (even if it's
kind of punting) we can allow the user to choose whether they want word
or substring detection and warn about the limitations of this
implementation in, say, docs.

[1]: https://unicode.org/reports/tr29/
     https://web.archive.org/web/20171001005125/https://unicode.org/reports/tr29/
2017-10-21 14:54:36 -05:00
David Yip
2e03a10059 Spike out index and new views for keyword mutes controller. 2017-10-21 14:54:36 -05:00
David Yip
4fa2f7e82d Set up /settings/keyword_mutes. #164.
This should eventually be accessible via the API and the web frontend,
but I find it easier to set up an editing interface using Rails
templates and the like.  We can always take it out if it turns out we
don't need it.
2017-10-21 14:54:36 -05:00
David Yip
b4b657eb1d Invalidate cached matcher objects on KeywordMute commit. #164. 2017-10-21 14:54:36 -05:00
David Yip
693c66dfde Use more idiomatic string concatentation. #164.
The intent of the previous concatenation was to minimize object
allocations, which can end up being a slow killer.  However, it turns
out that under MRI 2.4.x, the shove-strings-in-an-array-and-join method
is not only arguably more common but (in this particular case) actually
allocates *fewer* objects than the string concatenation.

Or, at least, that's what I gather by running this:

    words = %w(palmettoes nudged hibernation bullish stockade's tightened Hades
    Dixie's formalize superego's commissaries Zappa's viceroy's apothecaries
    tablespoonful's barons Chennai tollgate ticked expands)

    a = Account.first

    KeywordMute.transaction do
      words.each { |w| KeywordMute.create!(keyword: w, account: a) }

      GC.start

      s1 = GC.stat

      re = String.new.tap do |str|
        scoped = KeywordMute.where(account: a)
        keywords = scoped.select(:id, :keyword)
        count = scoped.count

        keywords.find_each.with_index do |kw, index|
          str << Regexp.escape(kw.keyword.strip)
          str << '|' if index < count - 1
        end
      end

      s2 = GC.stat

      puts s1.inspect, s2.inspect

      raise ActiveRecord::Rollback
    end

vs this:

    words = %w( palmettoes nudged hibernation bullish stockade's tightened Hades Dixie's
    formalize superego's commissaries Zappa's viceroy's apothecaries tablespoonful's
    barons Chennai tollgate ticked expands
    )

    a = Account.first

    KeywordMute.transaction do
      words.each { |w| KeywordMute.create!(keyword: w, account: a) }

      GC.start

      s1 = GC.stat

      re = [].tap do |arr|
        KeywordMute.where(account: a).select(:keyword, :id).find_each do |m|
          arr << Regexp.escape(m.keyword.strip)
        end
      end.join('|')

      s2 = GC.stat

      puts s1.inspect, s2.inspect

      raise ActiveRecord::Rollback
    end

Using rails r, here is a comparison of the total_allocated_objects and
malloc_increase_bytes GC stat data:

                 total_allocated_objects        malloc_increase_bytes
string concat    3200241 -> 3201428 (+1187)     1176 -> 45216 (44040)
array join       3200380 -> 3201299 (+919)      1176 -> 36448 (35272)
2017-10-21 14:54:36 -05:00
David Yip
a4851100fd Make use of the regex attr_reader. #164.
It would also have been valid to get rid of the attr_reader, but I like
being able to reach inside KeywordMute::Matcher without resorting to
instance_variable_get tomfoolery.
2017-10-21 14:54:36 -05:00
David Yip
9f609bc94e Fix case-insensitive match scenario; test some word ornamentation. #164. 2017-10-21 14:54:36 -05:00
David Yip
603cf02b70 Rework KeywordMute interface to use a matcher object; spec out matcher. #164.
A matcher object that builds a match from KeywordMute data and runs it
over text is, in my view, one of the easier ways to write examples for
this sort of thing.
2017-10-21 14:54:36 -05:00
David Yip
4745d6eeca Spec out KeywordMute interface. #164. 2017-10-21 14:54:21 -05:00
David Yip
9093e2de7a Add KeywordMute model.
Gist of the proposed keyword mute implementation:

Keyword mutes are represented server-side as one keyword per record.
For each account, there exists a keyword regex that is generated as one
big alternation of all keywords.  This regex is cached (in Redis, I
guess) so we can quickly get it when filtering in FeedManager.
2017-10-21 14:53:41 -05:00
317 changed files with 6274 additions and 2771 deletions

View File

@@ -35,6 +35,17 @@ PAPERCLIP_SECRET=$PAPERCLIP_SECRET
SECRET_KEY_BASE=$SECRET_KEY_BASE
OTP_SECRET=$OTP_SECRET
# VAPID keys (used for push notifications)
# You can generate the keys using the following command (first is the private key, second is the public one)
# You should only generate this once per instance. If you later decide to change it, all push subscription will
# be invalidated, requiring the users to access the website again to resubscribe.
#
# Generate with `rake mastodon:webpush:generate_vapid_key` task (`nanobox run bundle exec rake mastodon:webpush:generate_vapid_key`)
#
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
VAPID_PRIVATE_KEY=$VAPID_PRIVATE_KEY
VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY
# Registrations
# Single user mode will disable registrations and redirect frontpage to the first profile
# SINGLE_USER_MODE=true
@@ -62,7 +73,7 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
#SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt
#SMTP_OPENSSL_VERIFY_MODE=peer
#SMTP_ENABLE_STARTTLS_AUTO=true
#SMTP_TLS=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
@@ -91,6 +102,23 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
# S3_ENDPOINT=
# S3_SIGNATURE_VERSION=
# Swift (optional)
# SWIFT_ENABLED=true
# SWIFT_USERNAME=
# For Keystone V3, the value for SWIFT_TENANT should be the project name
# SWIFT_TENANT=
# SWIFT_PASSWORD=
# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid
# issues with token rate-limiting during high load.
# SWIFT_AUTH_URL=
# SWIFT_CONTAINER=
# SWIFT_OBJECT_URL=
# SWIFT_REGION=
# Defaults to 'default'
# SWIFT_DOMAIN_NAME=
# Defaults to 60 seconds. Set to 0 to disable
# SWIFT_CACHE_TTL=
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
# S3_CLOUDFRONT_HOST=

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "app/javascript/themes/mastodon-go"]
path = app/javascript/themes/mastodon-go
url = https://github.com/marrus-sh/mastodon-go

46
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at eugen@zeonfederated.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

32
Gemfile
View File

@@ -14,8 +14,10 @@ gem 'pg', '~> 0.20'
gem 'pghero', '~> 1.7'
gem 'dotenv-rails', '~> 2.2'
gem 'aws-sdk', '~> 2.9'
gem 'fog-openstack', '~> 0.1'
gem 'fog-aws', '~> 1.4', require: false
gem 'fog-core', '~> 1.45'
gem 'fog-local', '~> 0.4', require: false
gem 'fog-openstack', '~> 0.1', require: false
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder', '~> 0.6'
@@ -38,14 +40,14 @@ gem 'http', '~> 2.2'
gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 0.99'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.0'
gem 'kaminari', '~> 1.1'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.1'
gem 'nokogiri', '~> 1.7'
gem 'nokogiri', '~> 1.8'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.0'
gem 'oj', '~> 3.3'
gem 'ostatus2', '~> 2.0'
gem 'ox', '~> 2.5'
gem 'ox', '~> 2.8'
gem 'pundit', '~> 1.1'
gem 'rabl', '~> 0.13'
gem 'rack-attack', '~> 5.0'
@@ -75,15 +77,15 @@ gem 'json-ld-preloaded', '~> 2.2.1'
gem 'rdf-normalize', '~> 0.3.1'
group :development, :test do
gem 'fabrication', '~> 2.16'
gem 'fabrication', '~> 2.18'
gem 'fuubar', '~> 2.2'
gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 3.6'
gem 'rspec-rails', '~> 3.7'
end
group :test do
gem 'capybara', '~> 2.14'
gem 'capybara', '~> 2.15'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.7'
gem 'microformats', '~> 4.0'
@@ -91,13 +93,13 @@ group :test do
gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.14', require: false
gem 'webmock', '~> 3.0'
gem 'parallel_tests', '~> 2.14'
gem 'parallel_tests', '~> 2.17'
end
group :development do
gem 'active_record_query_trace', '~> 1.5'
gem 'annotate', '~> 2.7'
gem 'better_errors', '~> 2.1'
gem 'better_errors', '~> 2.4'
gem 'binding_of_caller', '~> 0.7'
gem 'bullet', '~> 5.5'
gem 'letter_opener', '~> 1.4'
@@ -105,15 +107,15 @@ group :development do
gem 'rubocop', require: false
gem 'brakeman', '~> 4.0', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.53', require: false
gem 'scss_lint', '~> 0.55', require: false
gem 'capistrano', '~> 3.8'
gem 'capistrano-rails', '~> 1.2'
gem 'capistrano', '~> 3.10'
gem 'capistrano-rails', '~> 1.3'
gem 'capistrano-rbenv', '~> 2.1'
gem 'capistrano-yarn', '~> 2.0'
end
group :production do
gem 'lograge', '~> 0.5'
gem 'lograge', '~> 0.7'
gem 'redis-rails', '~> 5.0'
end

View File

@@ -57,25 +57,17 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-sdk (2.10.46)
aws-sdk-resources (= 2.10.46)
aws-sdk-core (2.10.46)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-resources (2.10.46)
aws-sdk-core (= 2.10.46)
aws-sigv4 (1.0.2)
bcrypt (3.1.11)
better_errors (2.3.0)
better_errors (2.4.0)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
binding_of_caller (0.7.2)
binding_of_caller (0.7.3)
debug_inspector (>= 0.0.1)
bootsnap (1.1.3)
bootsnap (1.1.5)
msgpack (~> 1.0)
brakeman (4.0.1)
browser (2.5.1)
browser (2.5.2)
builder (3.2.3)
bullet (5.6.1)
activesupport (>= 3.0.0)
@@ -83,23 +75,23 @@ GEM
bundler-audit (0.6.0)
bundler (~> 1.2)
thor (~> 0.18)
capistrano (3.9.1)
capistrano (3.10.0)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
capistrano-bundler (1.2.0)
capistrano-bundler (1.3.0)
capistrano (~> 3.1)
sshkit (~> 1.2)
capistrano-rails (1.3.0)
capistrano (~> 3.1)
capistrano-bundler (~> 1.1)
capistrano-rbenv (2.1.1)
capistrano-rbenv (2.1.2)
capistrano (~> 3.1)
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (2.15.1)
capybara (2.15.4)
addressable
mini_mime (>= 0.1.3)
nokogiri (>= 1.3.3)
@@ -110,7 +102,7 @@ GEM
activesupport
charlock_holmes (0.7.5)
chunky_png (1.3.8)
cld3 (3.2.0)
cld3 (3.2.1)
ffi (>= 1.1.0, < 1.10.0)
climate_control (0.2.0)
cocaine (0.5.8)
@@ -150,16 +142,21 @@ GEM
thread
thread_safe
encryptor (3.0.0)
erubi (1.6.1)
et-orbi (1.0.5)
erubi (1.7.0)
et-orbi (1.0.8)
tzinfo
excon (0.59.0)
execjs (2.7.0)
fabrication (2.16.3)
fabrication (2.18.0)
faker (1.8.4)
i18n (~> 0.5)
fast_blank (1.0.0)
ffi (1.9.18)
fog-aws (1.4.1)
fog-core (~> 1.38)
fog-json (~> 1.0)
fog-xml (~> 0.1)
ipaddress (~> 0.8)
fog-core (1.45.0)
builder
excon (~> 0.58)
@@ -167,15 +164,20 @@ GEM
fog-json (1.0.2)
fog-core (~> 1.0)
multi_json (~> 1.10)
fog-openstack (0.1.21)
fog-local (0.4.0)
fog-core (~> 1.27)
fog-openstack (0.1.22)
fog-core (>= 1.40)
fog-json (>= 1.0)
ipaddress (>= 0.8)
fog-xml (0.1.3)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
formatador (0.2.5)
fuubar (2.2.0)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
globalid (0.4.0)
globalid (0.4.1)
activesupport (>= 4.2.0)
goldfinger (2.0.1)
addressable (~> 2.5)
@@ -211,7 +213,8 @@ GEM
httplog (0.99.7)
colorize
rack
i18n (0.8.6)
i18n (0.9.0)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.18)
activesupport (>= 4.0.2)
ast (>= 2.1.0)
@@ -225,29 +228,28 @@ GEM
idn-ruby (0.1.0)
ipaddress (0.8.3)
iso-639 (0.2.8)
jmespath (1.3.1)
json (2.1.0)
json-ld (2.1.5)
json-ld (2.1.7)
multi_json (~> 1.12)
rdf (~> 2.2)
rdf (~> 2.2, >= 2.2.8)
json-ld-preloaded (2.2.2)
json-ld (~> 2.1, >= 2.1.5)
multi_json (~> 1.11)
rdf (~> 2.2)
jsonapi-renderer (0.1.3)
jwt (1.5.6)
kaminari (1.0.1)
kaminari (1.1.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.0.1)
kaminari-activerecord (= 1.0.1)
kaminari-core (= 1.0.1)
kaminari-actionview (1.0.1)
kaminari-actionview (= 1.1.1)
kaminari-activerecord (= 1.1.1)
kaminari-core (= 1.1.1)
kaminari-actionview (1.1.1)
actionview
kaminari-core (= 1.0.1)
kaminari-activerecord (1.0.1)
kaminari-core (= 1.1.1)
kaminari-activerecord (1.1.1)
activerecord
kaminari-core (= 1.0.1)
kaminari-core (1.0.1)
kaminari-core (= 1.1.1)
kaminari-core (1.1.1)
launchy (2.4.3)
addressable (~> 2.3)
letter_opener (1.4.1)
@@ -257,18 +259,19 @@ GEM
letter_opener (~> 1.0)
railties (>= 3.2)
link_header (0.0.8)
lograge (0.6.0)
lograge (0.7.1)
actionpack (>= 4, < 5.2)
activesupport (>= 4, < 5.2)
railties (>= 4, < 5.2)
request_store (~> 1.0)
loofah (2.0.3)
loofah (2.1.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.6.6)
mime-types (>= 1.16, < 4)
mario-redis-lock (1.2.0)
redis (~> 3, >= 3.0.5)
method_source (0.8.2)
method_source (0.9.0)
microformats (4.0.7)
json
nokogiri
@@ -277,7 +280,7 @@ GEM
mime-types-data (3.2016.0521)
mimemagic (0.3.2)
mini_mime (0.1.4)
mini_portile2 (2.2.0)
mini_portile2 (2.3.0)
minitest (5.10.3)
msgpack (1.1.0)
multi_json (1.12.2)
@@ -285,8 +288,8 @@ GEM
net-ssh (>= 2.6.5)
net-ssh (4.2.0)
nio4r (2.1.0)
nokogiri (1.8.0)
mini_portile2 (~> 2.2.0)
nokogiri (1.8.1)
mini_portile2 (~> 2.3.0)
nokogumbo (1.4.13)
nokogiri
nsa (0.2.4)
@@ -294,15 +297,15 @@ GEM
concurrent-ruby (~> 1.0.0)
sidekiq (>= 3.5.0)
statsd-ruby (~> 1.2.0)
oj (3.3.5)
openssl (2.0.5)
oj (3.3.9)
openssl (2.0.6)
orm_adapter (0.5.0)
ostatus2 (2.0.1)
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
openssl (~> 2.0)
ox (2.6.0)
ox (2.8.1)
paperclip (5.1.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
@@ -313,19 +316,18 @@ GEM
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.12.0)
parallel_tests (2.15.0)
parallel_tests (2.17.0)
parallel
parser (2.4.0.0)
ast (~> 2.2)
pg (0.21.0)
pghero (1.7.0)
activerecord
pkg-config (1.2.7)
pkg-config (1.2.8)
powerpack (0.1.1)
pry (0.10.4)
pry (0.11.2)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
slop (~> 3.4)
method_source (~> 0.9.0)
pry-rails (0.3.6)
pry (>= 0.10.4)
public_suffix (3.0.0)
@@ -379,31 +381,31 @@ GEM
thor (>= 0.18.1, < 2.0)
rainbow (2.2.2)
rake
rake (12.1.0)
rdf (2.2.9)
rake (12.2.1)
rdf (2.2.11)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.2)
rdf (~> 2.0)
redis (3.3.3)
redis-actionpack (5.0.1)
redis (3.3.5)
redis-actionpack (5.0.2)
actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3)
redis-store (>= 1.1.0, < 1.4.0)
redis-activesupport (5.0.3)
redis-store (>= 1.1.0, < 2)
redis-activesupport (5.0.4)
activesupport (>= 3, < 6)
redis-store (~> 1.3.0)
redis-store (>= 1.3, < 2)
redis-namespace (1.5.3)
redis (~> 3.0, >= 3.0.4)
redis-rack (2.0.2)
redis-rack (2.0.3)
rack (>= 1.5, < 3)
redis-store (>= 1.2, < 1.4)
redis-store (>= 1.2, < 2)
redis-rails (5.0.2)
redis-actionpack (>= 5.0, < 6)
redis-activesupport (>= 5.0, < 6)
redis-store (>= 1.2, < 2)
redis-store (1.3.0)
redis (>= 2.2)
redis-store (1.4.1)
redis (>= 2.2, < 5)
request_store (1.3.2)
responders (2.4.0)
actionpack (>= 4.2.0, < 5.3)
@@ -411,27 +413,27 @@ GEM
rotp (2.1.2)
rqrcode (0.10.1)
chunky_png (~> 1.0)
rspec-core (3.6.0)
rspec-support (~> 3.6.0)
rspec-expectations (3.6.0)
rspec-core (3.7.0)
rspec-support (~> 3.7.0)
rspec-expectations (3.7.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.6.0)
rspec-mocks (3.6.0)
rspec-support (~> 3.7.0)
rspec-mocks (3.7.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.6.0)
rspec-rails (3.6.1)
rspec-support (~> 3.7.0)
rspec-rails (3.7.1)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
rspec-core (~> 3.6.0)
rspec-expectations (~> 3.6.0)
rspec-mocks (~> 3.6.0)
rspec-support (~> 3.6.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.7.0)
rspec-support (~> 3.7.0)
rspec-sidekiq (3.0.3)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.6.0)
rubocop (0.50.0)
rspec-support (3.7.0)
rubocop (0.51.0)
parallel (~> 1.10)
parser (>= 2.3.3.1, < 3.0)
powerpack (~> 0.1)
@@ -439,7 +441,7 @@ GEM
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.0, >= 1.0.1)
ruby-oembed (0.12.0)
ruby-progressbar (1.8.3)
ruby-progressbar (1.9.0)
rufus-scheduler (3.4.2)
et-orbi (~> 1.0)
safe_yaml (1.0.4)
@@ -448,19 +450,19 @@ GEM
nokogiri (>= 1.4.4)
nokogumbo (~> 1.4.1)
sass (3.4.25)
scss_lint (0.54.0)
scss_lint (0.55.0)
rake (>= 0.9, < 13)
sass (~> 3.4.20)
sidekiq (5.0.4)
sidekiq (5.0.5)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0)
redis (~> 3.3, >= 3.3.3)
redis (>= 3.3.4, < 5)
sidekiq-bulk (0.1.1)
activesupport
sidekiq
sidekiq-scheduler (2.1.9)
redis (~> 3)
sidekiq-scheduler (2.1.10)
redis (>= 3, < 5)
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
tilt (>= 1.4.0)
@@ -477,7 +479,6 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
slop (3.6.0)
sprockets (3.7.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
@@ -500,9 +501,9 @@ GEM
tilt (2.0.8)
twitter-text (1.14.7)
unf (~> 0.1.0)
tzinfo (1.2.3)
tzinfo (1.2.4)
thread_safe (~> 0.1)
tzinfo-data (1.2017.2)
tzinfo-data (1.2017.3)
tzinfo (>= 1.0.0)
uglifier (3.2.0)
execjs (>= 0.3.0, < 3)
@@ -517,7 +518,7 @@ GEM
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
webpacker (3.0.1)
webpacker (3.0.2)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
@@ -538,19 +539,18 @@ DEPENDENCIES
active_record_query_trace (~> 1.5)
addressable (~> 2.5)
annotate (~> 2.7)
aws-sdk (~> 2.9)
better_errors (~> 2.1)
better_errors (~> 2.4)
binding_of_caller (~> 0.7)
bootsnap
brakeman (~> 4.0)
browser
bullet (~> 5.5)
bundler-audit (~> 0.6)
capistrano (~> 3.8)
capistrano-rails (~> 1.2)
capistrano (~> 3.10)
capistrano-rails (~> 1.3)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 2.14)
capybara (~> 2.15)
charlock_holmes (~> 0.7.5)
cld3 (~> 3.2.0)
climate_control (~> 0.2)
@@ -558,9 +558,12 @@ DEPENDENCIES
devise-two-factor (~> 3.0)
doorkeeper (~> 4.2)
dotenv-rails (~> 2.2)
fabrication (~> 2.16)
fabrication (~> 2.18)
faker (~> 1.7)
fast_blank (~> 1.0)
fog-aws (~> 1.4)
fog-core (~> 1.45)
fog-local (~> 0.4)
fog-openstack (~> 0.1)
fuubar (~> 2.2)
goldfinger (~> 2.0)
@@ -574,22 +577,22 @@ DEPENDENCIES
idn-ruby
iso-639
json-ld-preloaded (~> 2.2.1)
kaminari (~> 1.0)
kaminari (~> 1.1)
letter_opener (~> 1.4)
letter_opener_web (~> 1.3)
link_header (~> 0.0)
lograge (~> 0.5)
lograge (~> 0.7)
mario-redis-lock (~> 1.2)
microformats (~> 4.0)
mime-types (~> 3.1)
nokogiri (~> 1.7)
nokogiri (~> 1.8)
nsa (~> 0.2)
oj (~> 3.0)
oj (~> 3.3)
ostatus2 (~> 2.0)
ox (~> 2.5)
ox (~> 2.8)
paperclip (~> 5.1)
paperclip-av-transcoder (~> 0.6)
parallel_tests (~> 2.14)
parallel_tests (~> 2.17)
pg (~> 0.20)
pghero (~> 1.7)
pkg-config (~> 1.2)
@@ -609,12 +612,12 @@ DEPENDENCIES
redis-namespace (~> 1.5)
redis-rails (~> 5.0)
rqrcode (~> 0.10)
rspec-rails (~> 3.6)
rspec-rails (~> 3.7)
rspec-sidekiq (~> 3.0)
rubocop
ruby-oembed (~> 0.12)
sanitize (~> 4.4)
scss_lint (~> 0.53)
scss_lint (~> 0.55)
sidekiq (~> 5.0)
sidekiq-bulk (~> 0.1.1)
sidekiq-scheduler (~> 2.1)

2
Vagrantfile vendored
View File

@@ -83,7 +83,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"
vb.customize ["modifyvm", :id, "--memory", "2048"]
vb.customize ["modifyvm", :id, "--memory", "4096"]
# Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
# https://github.com/mitchellh/vagrant/issues/1172

View File

@@ -1,31 +1,41 @@
# frozen_string_literal: true
class Admin::AccountModerationNotesController < Admin::BaseController
def create
@account_moderation_note = current_account.account_moderation_notes.new(resource_params)
if @account_moderation_note.save
@target_account = @account_moderation_note.target_account
redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg')
else
@account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest
render template: 'admin/accounts/show'
module Admin
class AccountModerationNotesController < BaseController
before_action :set_account_moderation_note, only: [:destroy]
def create
authorize AccountModerationNote, :create?
@account_moderation_note = current_account.account_moderation_notes.new(resource_params)
if @account_moderation_note.save
redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg')
else
@account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest
render template: 'admin/accounts/show'
end
end
def destroy
authorize @account_moderation_note, :destroy?
@account_moderation_note.destroy
redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
end
private
def resource_params
params.require(:account_moderation_note).permit(
:content,
:target_account_id
)
end
def set_account_moderation_note
@account_moderation_note = AccountModerationNote.find(params[:id])
end
end
def destroy
@account_moderation_note = AccountModerationNote.find(params[:id])
@target_account = @account_moderation_note.target_account
@account_moderation_note.destroy
redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
end
private
def resource_params
params.require(:account_moderation_note).permit(
:content,
:target_account_id
)
end
end

View File

@@ -2,29 +2,54 @@
module Admin
class AccountsController < BaseController
before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload]
before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :enable, :disable, :memorialize]
before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload]
before_action :require_local_account!, only: [:enable, :disable, :memorialize]
def index
authorize :account, :index?
@accounts = filtered_accounts.page(params[:page])
end
def show
authorize @account, :show?
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest
end
def subscribe
authorize @account, :subscribe?
Pubsubhubbub::SubscribeWorker.perform_async(@account.id)
redirect_to admin_account_path(@account.id)
end
def unsubscribe
authorize @account, :unsubscribe?
Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id)
redirect_to admin_account_path(@account.id)
end
def memorialize
authorize @account, :memorialize?
@account.memorialize!
redirect_to admin_account_path(@account.id)
end
def enable
authorize @account.user, :enable?
@account.user.enable!
redirect_to admin_account_path(@account.id)
end
def disable
authorize @account.user, :disable?
@account.user.disable!
redirect_to admin_account_path(@account.id)
end
def redownload
authorize @account, :redownload?
@account.reset_avatar!
@account.reset_header!
@account.save!
@@ -42,6 +67,10 @@ module Admin
redirect_to admin_account_path(@account.id) if @account.local?
end
def require_local_account!
redirect_to admin_account_path(@account.id) unless @account.local? && @account.user.present?
end
def filtered_accounts
AccountFilter.new(filter_params).results
end

View File

@@ -2,7 +2,9 @@
module Admin
class BaseController < ApplicationController
before_action :require_admin!
include Authorization
before_action :require_staff!
layout 'admin'
end

View File

@@ -2,15 +2,18 @@
module Admin
class ConfirmationsController < BaseController
before_action :set_user
def create
account_user.confirm
authorize @user, :confirm?
@user.confirm!
redirect_to admin_accounts_path
end
private
def account_user
Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
def set_user
@user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
end
end
end

View File

@@ -5,14 +5,18 @@ module Admin
before_action :set_custom_emoji, except: [:index, :new, :create]
def index
@custom_emojis = filtered_custom_emojis.page(params[:page])
authorize :custom_emoji, :index?
@custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
end
def new
authorize :custom_emoji, :create?
@custom_emoji = CustomEmoji.new
end
def create
authorize :custom_emoji, :create?
@custom_emoji = CustomEmoji.new(resource_params)
if @custom_emoji.save
@@ -22,29 +26,44 @@ module Admin
end
end
def update
authorize @custom_emoji, :update?
if @custom_emoji.update(resource_params)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
else
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
end
end
def destroy
authorize @custom_emoji, :destroy?
@custom_emoji.destroy
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
end
def copy
emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image)
authorize @custom_emoji, :copy?
if emoji.save
emoji = CustomEmoji.find_or_create_by(domain: nil, shortcode: @custom_emoji.shortcode)
if emoji.update(image: @custom_emoji.image)
flash[:notice] = I18n.t('admin.custom_emojis.copied_msg')
else
flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
end
redirect_to admin_custom_emojis_path(params[:page])
redirect_to admin_custom_emojis_path(page: params[:page])
end
def enable
authorize @custom_emoji, :enable?
@custom_emoji.update!(disabled: false)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
end
def disable
authorize @custom_emoji, :disable?
@custom_emoji.update!(disabled: true)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
end
@@ -56,7 +75,7 @@ module Admin
end
def resource_params
params.require(:custom_emoji).permit(:shortcode, :image)
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
end
def filtered_custom_emojis

View File

@@ -5,14 +5,18 @@ module Admin
before_action :set_domain_block, only: [:show, :destroy]
def index
authorize :domain_block, :index?
@domain_blocks = DomainBlock.page(params[:page])
end
def new
authorize :domain_block, :create?
@domain_block = DomainBlock.new
end
def create
authorize :domain_block, :create?
@domain_block = DomainBlock.new(resource_params)
if @domain_block.save
@@ -23,9 +27,12 @@ module Admin
end
end
def show; end
def show
authorize @domain_block, :show?
end
def destroy
authorize @domain_block, :destroy?
UnblockDomainService.new.call(@domain_block, retroactive_unblock?)
redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg')
end

View File

@@ -5,14 +5,18 @@ module Admin
before_action :set_email_domain_block, only: [:show, :destroy]
def index
authorize :email_domain_block, :index?
@email_domain_blocks = EmailDomainBlock.page(params[:page])
end
def new
authorize :email_domain_block, :create?
@email_domain_block = EmailDomainBlock.new
end
def create
authorize :email_domain_block, :create?
@email_domain_block = EmailDomainBlock.new(resource_params)
if @email_domain_block.save
@@ -23,6 +27,7 @@ module Admin
end
def destroy
authorize @email_domain_block, :destroy?
@email_domain_block.destroy
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
end

View File

@@ -3,10 +3,12 @@
module Admin
class InstancesController < BaseController
def index
authorize :instance, :index?
@instances = ordered_instances
end
def resubscribe
authorize :instance, :resubscribe?
params.require(:by_domain)
Pubsubhubbub::SubscribeWorker.push_bulk(subscribeable_accounts.pluck(:id))
redirect_to admin_instances_path

View File

@@ -2,19 +2,20 @@
module Admin
class ReportedStatusesController < BaseController
include Authorization
before_action :set_report
before_action :set_status, only: [:update, :destroy]
def create
@form = Form::StatusBatch.new(form_status_batch_params)
flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save
authorize :status, :update?
@form = Form::StatusBatch.new(form_status_batch_params)
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_report_path(@report)
end
def update
authorize @status, :update?
@status.update(status_params)
redirect_to admin_report_path(@report)
end

View File

@@ -5,14 +5,17 @@ module Admin
before_action :set_report, except: [:index]
def index
authorize :report, :index?
@reports = filtered_reports.page(params[:page])
end
def show
authorize @report, :show?
@form = Form::StatusBatch.new
end
def update
authorize @report, :update?
process_report
redirect_to admin_report_path(@report)
end

View File

@@ -2,17 +2,18 @@
module Admin
class ResetsController < BaseController
before_action :set_account
before_action :set_user
def create
@account.user.send_reset_password_instructions
authorize @user, :reset_password?
@user.send_reset_password_instructions
redirect_to admin_accounts_path
end
private
def set_account
@account = Account.find(params[:account_id])
def set_user
@user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
end
end
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Admin
class RolesController < BaseController
before_action :set_user
def promote
authorize @user, :promote?
@user.promote!
redirect_to admin_account_path(@user.account_id)
end
def demote
authorize @user, :demote?
@user.demote!
redirect_to admin_account_path(@user.account_id)
end
private
def set_user
@user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
end
end
end

View File

@@ -28,10 +28,13 @@ module Admin
).freeze
def edit
authorize :settings, :show?
@admin_settings = Form::AdminSettings.new
end
def update
authorize :settings, :update?
settings_params.each do |key, value|
if UPLOAD_SETTINGS.include?(key)
upload = SiteUpload.where(var: key).first_or_initialize(var: key)

View File

@@ -5,11 +5,13 @@ module Admin
before_action :set_account
def create
authorize @account, :silence?
@account.update(silenced: true)
redirect_to admin_accounts_path
end
def destroy
authorize @account, :unsilence?
@account.update(silenced: false)
redirect_to admin_accounts_path
end

View File

@@ -2,8 +2,6 @@
module Admin
class StatusesController < BaseController
include Authorization
helper_method :current_params
before_action :set_account
@@ -12,24 +10,30 @@ module Admin
PER_PAGE = 20
def index
authorize :status, :index?
@statuses = @account.statuses
if params[:media]
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
@statuses.merge!(Status.where(id: account_media_status_ids))
end
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
@form = Form::StatusBatch.new
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
@form = Form::StatusBatch.new
end
def create
@form = Form::StatusBatch.new(form_status_batch_params)
flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save
authorize :status, :update?
@form = Form::StatusBatch.new(form_status_batch_params)
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_account_statuses_path(@account.id, current_params)
end
def update
authorize @status, :update?
@status.update(status_params)
redirect_to admin_account_statuses_path(@account.id, current_params)
end
@@ -60,6 +64,7 @@ module Admin
def current_params
page = (params[:page] || 1).to_i
{
media: params[:media],
page: page > 1 && page,

View File

@@ -3,6 +3,7 @@
module Admin
class SubscriptionsController < BaseController
def index
authorize :subscription, :index?
@subscriptions = ordered_subscriptions.page(requested_page)
end

View File

@@ -5,12 +5,14 @@ module Admin
before_action :set_account
def create
authorize @account, :suspend?
Admin::SuspensionWorker.perform_async(@account.id)
redirect_to admin_accounts_path
end
def destroy
@account.update(suspended: false)
authorize @account, :unsuspend?
@account.unsuspend!
redirect_to admin_accounts_path
end

View File

@@ -5,6 +5,7 @@ module Admin
before_action :set_user
def destroy
authorize @user, :disable_2fa?
@user.disable_two_factor!
redirect_to admin_accounts_path
end

View File

@@ -13,9 +13,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def follow
FollowService.new.call(current_user.account, @account.acct)
reblogs_arg = { reblogs: params[:reblogs] }
FollowService.new.call(current_user.account, @account.acct, reblogs_arg)
options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } }
options = @account.locked? ? {} : { following_map: { @account.id => reblogs_arg }, requested_map: { @account.id => false } }
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
end

View File

@@ -19,7 +19,7 @@ class Api::V1::ReportsController < Api::BaseController
comment: report_params[:comment]
)
User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
User.staff.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
render json: @report, serializer: REST::ReportSerializer
end

View File

@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Api::V1::SearchController < Api::BaseController
include Authorization
RESULTS_LIMIT = 10
before_action -> { doorkeeper_authorize! :read }
@@ -9,12 +11,24 @@ class Api::V1::SearchController < Api::BaseController
respond_to :json
def index
@search = Search.new(search_results)
@search = Search.new(search)
render json: @search, serializer: REST::SearchSerializer
end
private
def search
search_results.tap do |search|
search[:statuses].keep_if do |status|
begin
authorize status, :show?
rescue Mastodon::NotPermittedError
false
end
end
end
end
def search_results
SearchService.new.call(
params[:q],

View File

@@ -0,0 +1,60 @@
# frozen_string_literal: true
class Api::V1::Timelines::DirectController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }, only: [:show]
before_action :require_user!, only: [:show]
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
respond_to :json
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def load_statuses
cached_direct_statuses
end
def cached_direct_statuses
cache_collection direct_statuses, Status
end
def direct_statuses
direct_timeline_statuses.paginate_by_max_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id]
)
end
def direct_timeline_statuses
Status.as_direct_timeline(current_account)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params)
params.permit(:local, :limit).merge(core_params)
end
def next_path
api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id)
end
def prev_path
api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id)
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
end

View File

@@ -13,11 +13,13 @@ class ApplicationController < ActionController::Base
helper_method :current_account
helper_method :current_session
helper_method :current_theme
helper_method :theme_data
helper_method :single_user_mode?
rescue_from ActionController::RoutingError, with: :not_found
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
rescue_from Mastodon::NotPermittedError, with: :forbidden
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
before_action :check_suspension, if: :user_signed_in?
@@ -40,6 +42,10 @@ class ApplicationController < ActionController::Base
redirect_to root_path unless current_user&.admin?
end
def require_staff!
redirect_to root_path unless current_user&.staff?
end
def check_suspension
forbidden if current_user.account.suspended?
end
@@ -83,6 +89,10 @@ class ApplicationController < ActionController::Base
current_user.setting_theme
end
def theme_data
Themes.instance.get(current_theme)
end
def cache_collection(raw, klass)
return raw unless klass.respond_to?(:with_includes)
@@ -99,7 +109,7 @@ class ApplicationController < ActionController::Base
unless uncached_ids.empty?
uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h
uncached.values.each do |item|
uncached.each_value do |item|
Rails.cache.write(item.cache_key, item)
end
end

View File

@@ -62,7 +62,7 @@ class Auth::SessionsController < Devise::SessionsController
if user_params[:otp_attempt].present? && session[:otp_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user && user.valid_password?(user_params[:password])
elsif user&.valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
end

View File

@@ -2,6 +2,7 @@
module Authorization
extend ActiveSupport::Concern
include Pundit
def pundit_user

View File

@@ -6,7 +6,6 @@ class HomeController < ApplicationController
def index
@body_classes = 'app-body'
@frontend = (params[:frontend] and Rails.configuration.x.available_frontends.include? params[:frontend] + '.js') ? params[:frontend] : 'mastodon'
end
private

View File

@@ -0,0 +1,64 @@
# frozen_string_literal: true
class Settings::KeywordMutesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :load_keyword_mute, only: [:edit, :update, :destroy]
def index
@keyword_mutes = paginated_keyword_mutes_for_account
end
def new
@keyword_mute = keyword_mutes_for_account.build
end
def create
@keyword_mute = keyword_mutes_for_account.create(keyword_mute_params)
if @keyword_mute.persisted?
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
else
render :new
end
end
def update
if @keyword_mute.update(keyword_mute_params)
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
else
render :edit
end
end
def destroy
@keyword_mute.destroy!
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
end
def destroy_all
keyword_mutes_for_account.delete_all
redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
end
private
def keyword_mutes_for_account
Glitch::KeywordMute.where(account: current_account)
end
def load_keyword_mute
@keyword_mute = keyword_mutes_for_account.find(params[:id])
end
def keyword_mute_params
params.require(:keyword_mute).permit(:keyword, :whole_word)
end
def paginated_keyword_mutes_for_account
keyword_mutes_for_account.order(:keyword).page params[:page]
end
end

View File

@@ -26,7 +26,7 @@ class Settings::NotificationsController < ApplicationController
def user_settings_params
params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following)
interactions: %i(must_be_follower must_be_following must_be_following_dm)
)
end
end

View File

@@ -18,7 +18,7 @@ module Admin::FilterHelper
def selected?(more_params)
new_url = filtered_url_for(more_params)
filter_link_class(new_url) == 'selected' ? true : false
filter_link_class(new_url) == 'selected'
end
private

View File

@@ -35,6 +35,11 @@ module ApplicationHelper
Rails.env.production? ? site_title : "#{site_title} (Dev)"
end
def can?(action, record)
return false if record.nil?
policy(record).public_send("#{action}?")
end
def fa_icon(icon, attributes = {})
class_names = attributes[:class]&.split(' ') || []
class_names << 'fa'
@@ -43,6 +48,10 @@ module ApplicationHelper
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
end
def custom_emoji_tag(custom_emoji)
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
end
def opengraph(property, content)
tag(:meta, content: content, property: property)
end

View File

@@ -9,6 +9,10 @@ module JsonLdHelper
value.is_a?(Array) ? value.first : value
end
def as_array(value)
value.is_a?(Array) ? value : [value]
end
def value_or_id(value)
value.is_a?(String) || value.nil? ? value : value['id']
end

View File

@@ -0,0 +1,2 @@
module Settings::KeywordMutesHelper
end

View File

@@ -51,6 +51,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import emojify from '../../../mastodon/features/emoji/emoji';
import IconButton from '../../../mastodon/components/icon_button';
import Avatar from '../../../mastodon/components/avatar';
import { me } from '../../../mastodon/initial_state';
// Our imports //
import { processBio } from '../../util/bio_metadata';
@@ -88,7 +89,6 @@ export default class AccountHeader extends ImmutablePureComponent {
static propTypes = {
account : ImmutablePropTypes.map,
me : PropTypes.string.isRequired,
onFollow : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
@@ -102,7 +102,7 @@ The `render()` function is used to render our component.
*/
render () {
const { account, me, intl } = this.props;
const { account, intl } = this.props;
/*
@@ -152,7 +152,7 @@ appropriate icon.
<IconButton
size={26}
icon={following ? 'user-times' : 'user-plus'}
active={following}
active={following ? true : false}
title={intl.formatMessage(following ? messages.unfollow : messages.follow)}
onClick={this.props.onFollow}
/>

View File

@@ -124,6 +124,16 @@ export default class LocalSettingsPage extends React.PureComponent {
>
<FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'reblogs']}
id='mastodon-settings--collapsed-auto-reblogs'
onChange={onChange}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['collapsed', 'auto', 'replies']}

View File

@@ -9,6 +9,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
import IconButton from '../../../mastodon/components/icon_button';
import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container';
import { me } from '../../../mastodon/initial_state';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -50,7 +51,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
onEmbed: PropTypes.func,
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
me: PropTypes.string,
withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@@ -59,7 +59,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'me',
'withDismiss',
]
@@ -119,7 +118,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
}
render () {
const { status, me, intl, withDismiss } = this.props;
const { status, intl, withDismiss } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me;

View File

@@ -140,12 +140,10 @@ Here are the props we pass to `<Status>`.
return {
status : status,
account : account || ownProps.account,
me : state.getIn(['meta', 'me']),
settings : state.get('local_settings'),
prepend : prepend || ownProps.prepend,
reblogModal : state.getIn(['meta', 'boost_modal']),
deleteModal : state.getIn(['meta', 'delete_modal']),
autoPlayGif : state.getIn(['meta', 'auto_play_gif']),
};
};

View File

@@ -39,6 +39,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
import { autoPlayGif } from '../../../mastodon/initial_state';
// Our imports //
import StatusPrepend from './prepend';
@@ -89,9 +90,6 @@ few parts:
These are our local settings, fetched from our store. We need this
to determine how best to collapse our statuses, among other things.
- __`me` (`PropTypes.number`) :__
This is the id of the currently-signed-in user.
- __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`,
`onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
`onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__
@@ -103,9 +101,6 @@ few parts:
reblogging and deleting statuses. They are used by the `onReblog`
and `onDelete` functions, but we don't deal with them here.
- __`autoPlayGif` (`PropTypes.bool`) :__
This tells the frontend whether or not to autoplay gifs!
- __`muted` (`PropTypes.bool`) :__
This has nothing to do with a user or conversation mute! "Muted" is
what Mastodon internally calls the subdued look of statuses in the
@@ -160,7 +155,6 @@ export default class Status extends ImmutablePureComponent {
account : ImmutablePropTypes.map,
settings : ImmutablePropTypes.map,
notification : ImmutablePropTypes.map,
me : PropTypes.string,
onFavourite : PropTypes.func,
onReblog : PropTypes.func,
onModalReblog : PropTypes.func,
@@ -177,7 +171,6 @@ export default class Status extends ImmutablePureComponent {
onOpenVideo : PropTypes.func,
reblogModal : PropTypes.bool,
deleteModal : PropTypes.bool,
autoPlayGif : PropTypes.bool,
muted : PropTypes.bool,
collapse : PropTypes.bool,
prepend : PropTypes.string,
@@ -211,9 +204,7 @@ to remember to specify it here.
'account',
'settings',
'prepend',
'me',
'boostModal',
'autoPlayGif',
'muted',
'collapse',
'notification',
@@ -287,6 +278,7 @@ properly and our intersection observer is good to go.
muted,
id,
intersectionObserverWrapper,
prepend,
} = this.props;
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
@@ -299,6 +291,9 @@ properly and our intersection observer is good to go.
node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
)
) || (
autoCollapseSettings.get('reblogs') &&
prepend === 'reblogged_by'
) || (
autoCollapseSettings.get('replies') &&
status.get('in_reply_to_id', null) !== null
@@ -556,7 +551,6 @@ this operation are further explained in the code below.
intersectionObserverWrapper,
onOpenVideo,
onOpenMedia,
autoPlayGif,
notification,
...other
} = this.props;

View File

@@ -14,6 +14,7 @@
"settings.auto_collapse_lengthy": "Lengthy toots",
"settings.auto_collapse_media": "Toots with media",
"settings.auto_collapse_notifications": "Notifications",
"settings.auto_collapse_reblogs": "Boosts",
"settings.auto_collapse_replies": "Replies",
"settings.close": "Close",
"settings.collapsed_statuses": "Collapsed toots",

View File

@@ -59,6 +59,7 @@ const initialState = ImmutableMap({
all : false,
notifications : true,
lengthy : true,
reblogs : false,
replies : false,
media : false,
}),

View File

@@ -105,12 +105,13 @@ export function fetchAccountFail(id, error) {
};
};
export function followAccount(id) {
export function followAccount(id, reblogs = true) {
return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
dispatch(followAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
dispatch(followAccountSuccess(response.data));
api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing));
}).catch(error => {
dispatch(followAccountFail(error));
});
@@ -136,10 +137,11 @@ export function followAccountRequest(id) {
};
};
export function followAccountSuccess(relationship) {
export function followAccountSuccess(relationship, alreadyFollowing) {
return {
type: ACCOUNT_FOLLOW_SUCCESS,
relationship,
alreadyFollowing,
};
};

View File

@@ -8,6 +8,7 @@ import {
refreshHomeTimeline,
refreshCommunityTimeline,
refreshPublicTimeline,
refreshDirectTimeline,
} from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
@@ -133,6 +134,8 @@ export function submitCompose() {
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
insertOrRefresh('community', refreshCommunityTimeline);
insertOrRefresh('public', refreshPublicTimeline);
} else if (response.data.visibility === 'direct') {
insertOrRefresh('direct', refreshDirectTimeline);
}
}).catch(function (error) {
dispatch(submitComposeFail(error));

View File

@@ -4,12 +4,13 @@ export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
import { me } from '../initial_state';
export function fetchPinnedStatuses() {
return (dispatch, getState) => {
dispatch(fetchPinnedStatusesRequest());
const accountId = getState().getIn(['meta', 'me']);
api(getState).get(`/api/v1/accounts/${accountId}/statuses`, { params: { pinned: true } }).then(response => {
api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
dispatch(fetchPinnedStatusesSuccess(response.data, null));
}).catch(error => {
dispatch(fetchPinnedStatusesFail(error));

View File

@@ -1,4 +1,4 @@
import createStream from '../stream';
import { connectStream } from '../stream';
import {
updateTimeline,
deleteFromTimelines,
@@ -12,42 +12,19 @@ import { getLocale } from '../locales';
const { messages } = getLocale();
export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
return (dispatch, getState) => {
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = getState().getIn(['meta', 'access_token']);
return connectStream (path, pollingRefresh, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']);
let polling = null;
const setupPolling = () => {
polling = setInterval(() => {
pollingRefresh(dispatch);
}, 20000);
};
const clearPolling = () => {
if (polling) {
clearInterval(polling);
polling = null;
}
};
const subscription = createStream(streamingAPIBaseURL, accessToken, path, {
connected () {
if (pollingRefresh) {
clearPolling();
}
return {
onConnect() {
dispatch(connectTimeline(timelineId));
},
disconnected () {
if (pollingRefresh) {
setupPolling();
}
onDisconnect() {
dispatch(disconnectTimeline(timelineId));
},
received (data) {
onReceive (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
@@ -60,26 +37,8 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
break;
}
},
reconnected () {
if (pollingRefresh) {
clearPolling();
pollingRefresh(dispatch);
}
dispatch(connectTimeline(timelineId));
},
});
const disconnect = () => {
if (subscription) {
subscription.close();
}
clearPolling();
};
return disconnect;
};
});
}
function refreshHomeTimelineAndNotification (dispatch) {
@@ -92,3 +51,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', '
export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
export const connectPublicStream = () => connectTimelineStream('public', 'public');
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');

View File

@@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) {
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct');
export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
@@ -155,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) {
export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct');
export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);

View File

@@ -112,3 +112,19 @@ exports[`<Button /> renders the props.text instead of children 1`] = `
foo
</button>
`;
exports[`<Button /> renders title if props.title is given 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
title="foo"
/>
`;

View File

@@ -72,4 +72,11 @@ describe('<Button />', () => {
expect(tree).toMatchSnapshot();
});
it('renders title if props.title is given', () => {
const component = renderer.create(<Button title='foo' />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -7,6 +7,7 @@ import Permalink from './permalink';
import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -23,7 +24,6 @@ export default class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
me: PropTypes.string.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
@@ -52,7 +52,7 @@ export default class Account extends ImmutablePureComponent {
}
render () {
const { account, me, intl, hidden } = this.props;
const { account, intl, hidden } = this.props;
if (!account) {
return <div />;
@@ -82,7 +82,7 @@ export default class Account extends ImmutablePureComponent {
} else if (muting) {
let hidingNotificationsButton;
if (muting.get('notifications')) {
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
} else {
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
}
@@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent {
</div>
);
} else {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following ? true : false} />;
}
}

View File

@@ -14,6 +14,7 @@ export default class Button extends React.PureComponent {
className: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node,
title: PropTypes.string,
};
static defaultProps = {
@@ -35,26 +36,26 @@ export default class Button extends React.PureComponent {
}
render () {
const style = {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
let attrs = {
className: classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--block': this.props.block,
}),
disabled: this.props.disabled,
onClick: this.handleClick,
ref: this.setRef,
style: {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
},
};
const className = classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--block': this.props.block,
});
if (this.props.title) attrs.title = this.props.title;
return (
<button
className={className}
disabled={this.props.disabled}
onClick={this.handleClick}
ref={this.setRef}
style={style}
>
<button {...attrs}>
{this.props.text || this.props.children}
</button>
);

View File

@@ -175,7 +175,9 @@ export default class ColumnHeader extends React.PureComponent {
<div className={wrapperClassName}>
<h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} />
{title}
<span className='column-header__title'>
{title}
</span>
<div className='column-header__buttons'>
{backButton}
{ notifCleaning ? (

View File

@@ -65,6 +65,7 @@ export default class IconButton extends React.PureComponent {
expanded,
icon,
inverted,
flip,
overlay,
pressed,
tabIndex,
@@ -78,8 +79,8 @@ export default class IconButton extends React.PureComponent {
overlayed: overlay,
});
const flipDeg = this.props.flip ? -180 : -360;
const rotateDeg = this.props.active ? flipDeg : 0;
const flipDeg = flip ? -180 : -360;
const rotateDeg = active ? flipDeg : 0;
const motionDefaultStyle = {
rotate: rotateDeg,
@@ -93,6 +94,25 @@ export default class IconButton extends React.PureComponent {
rotate: animate ? spring(rotateDeg, springOpts) : 0,
};
if (!animate) {
// Perf optimization: avoid unnecessary <Motion> components unless
// we actually need to animate.
return (
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
className={classes}
onClick={this.handleClick}
style={style}
tabIndex={tabIndex}
>
<i className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
</button>
);
}
return (
<Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
{({ rotate }) =>

View File

@@ -9,6 +9,7 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
import classNames from 'classnames';
import { autoPlayGif } from '../initial_state';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@@ -26,11 +27,9 @@ class Item extends React.PureComponent {
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool,
};
static defaultProps = {
autoPlayGif: false,
standalone: false,
index: 0,
size: 1,
@@ -50,7 +49,7 @@ class Item extends React.PureComponent {
}
hoverToPlay () {
const { attachment, autoPlayGif } = this.props;
const { attachment } = this.props;
return !autoPlayGif && attachment.get('type') === 'gifv';
}
@@ -142,7 +141,7 @@ class Item extends React.PureComponent {
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
@@ -184,11 +183,9 @@ export default class MediaGallery extends React.PureComponent {
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool,
};
static defaultProps = {
autoPlayGif: false,
standalone: false,
};
@@ -264,9 +261,9 @@ export default class MediaGallery extends React.PureComponent {
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
}
}

View File

@@ -1,5 +1,5 @@
import React, { PureComponent } from 'react';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import PropTypes from 'prop-types';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import LoadMore from './load_more';

View File

@@ -39,9 +39,6 @@ export default class Status extends ImmutablePureComponent {
onBlock: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
me: PropTypes.string,
boostModal: PropTypes.bool,
autoPlayGif: PropTypes.bool,
muted: PropTypes.bool,
hidden: PropTypes.bool,
onMoveUp: PropTypes.func,
@@ -57,9 +54,6 @@ export default class Status extends ImmutablePureComponent {
updateOnProps = [
'status',
'account',
'me',
'boostModal',
'autoPlayGif',
'muted',
'hidden',
]
@@ -200,7 +194,7 @@ export default class Status extends ImmutablePureComponent {
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />}
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
</Bundle>
);
}

View File

@@ -8,6 +8,7 @@ import IconButton from './icon_button';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -50,7 +51,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
onEmbed: PropTypes.func,
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
me: PropTypes.string,
withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@@ -59,7 +59,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'me',
'withDismiss',
]
@@ -119,7 +118,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
}
render () {
const { status, me, intl, withDismiss } = this.props;
const { status, intl, withDismiss } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me;

View File

@@ -13,6 +13,7 @@ import {
} from '../actions/accounts';
import { openModal } from '../actions/modal';
import { initMuteModal } from '../actions/mutes';
import { unfollowModal } from '../initial_state';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@@ -23,8 +24,6 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
me: state.getIn(['meta', 'me']),
unfollowModal: state.getIn(['meta', 'unfollow_modal']),
});
return mapStateToProps;
@@ -34,7 +33,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
if (this.unfollowModal) {
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.unfollowConfirm),

View File

@@ -6,15 +6,14 @@ import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import Compose from '../features/standalone/compose';
import initialState from '../initial_state';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const store = configureStore();
const initialStateContainer = document.getElementById('initial-state');
if (initialStateContainer !== null) {
const initialState = JSON.parse(initialStateContainer.textContent);
if (initialState) {
store.dispatch(hydrateStore(initialState));
}

View File

@@ -4,23 +4,18 @@ import PropTypes from 'prop-types';
import configureStore from '../store/configureStore';
import { showOnboardingOnce } from '../actions/onboarding';
import { BrowserRouter, Route } from 'react-router-dom';
import { ScrollContext } from 'react-router-scroll';
import { ScrollContext } from 'react-router-scroll-4';
import UI from '../features/ui';
import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import initialState from '../initial_state';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
export const store = configureStore();
const initialState = JSON.parse(document.getElementById('initial-state').textContent);
try {
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
} catch (e) {
initialState.local_settings = {};
}
const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);

View File

@@ -17,20 +17,18 @@ import {
pin,
unpin,
} from '../actions/interactions';
import {
blockAccount,
muteAccount,
} from '../actions/accounts';
import { blockAccount } from '../actions/accounts';
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
import { initMuteModal } from '../actions/mutes';
import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal } from '../initial_state';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' },
});
const makeMapStateToProps = () => {
@@ -38,10 +36,6 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.id),
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal']),
deleteModal: state.getIn(['meta', 'delete_modal']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
return mapStateToProps;
@@ -61,7 +55,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (e.shiftKey || !this.boostModal) {
if (e.shiftKey || !boostModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
@@ -90,7 +84,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
},
onDelete (status) {
if (!this.deleteModal) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
@@ -126,11 +120,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
},
onMute (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.muteConfirm),
onConfirm: () => dispatch(muteAccount(account.get('id'))),
}));
dispatch(initMuteModal(account));
},
onMuteConversation (status) {

View File

@@ -7,15 +7,14 @@ import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import PublicTimeline from '../features/standalone/public_timeline';
import HashtagTimeline from '../features/standalone/hashtag_timeline';
import initialState from '../initial_state';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const store = configureStore();
const initialStateContainer = document.getElementById('initial-state');
if (initialStateContainer !== null) {
const initialState = JSON.parse(initialStateContainer.textContent);
if (initialState) {
store.dispatch(hydrateStore(initialState));
}

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { Link } from 'react-router-dom';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
import { me } from '../../../initial_state';
const messages = defineMessages({
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
@@ -19,6 +20,8 @@ const messages = defineMessages({
media: { id: 'account.media', defaultMessage: 'Media' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
});
@injectIntl
@@ -26,10 +29,10 @@ export default class ActionBar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
me: PropTypes.string.isRequired,
onFollow: PropTypes.func,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
@@ -44,7 +47,7 @@ export default class ActionBar extends React.PureComponent {
}
render () {
const { account, me, intl } = this.props;
const { account, intl } = this.props;
let menu = [];
let extraInfo = '';
@@ -60,6 +63,15 @@ export default class ActionBar extends React.PureComponent {
if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
} else {
const following = account.getIn(['relationship', 'following']);
if (following) {
if (following.get('reblogs')) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
}
}
if (account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
} else {

View File

@@ -8,8 +8,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif, me } from '../../../initial_state';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -17,19 +17,10 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
});
const makeMapStateToProps = () => {
const mapStateToProps = state => ({
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
return mapStateToProps;
};
class Avatar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
state = {
@@ -47,7 +38,7 @@ class Avatar extends ImmutablePureComponent {
}
render () {
const { account, autoPlayGif } = this.props;
const { account } = this.props;
const { isHovered } = this.state;
return (
@@ -74,20 +65,17 @@ class Avatar extends ImmutablePureComponent {
}
@connect(makeMapStateToProps)
@injectIntl
export default class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
me: PropTypes.string.isRequired,
onFollow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
render () {
const { account, me, intl } = this.props;
const { account, intl } = this.props;
if (!account) {
return null;
@@ -127,7 +115,7 @@ export default class Header extends ImmutablePureComponent {
return (
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div>
<Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
<Avatar account={account} />
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>

View File

@@ -12,14 +12,13 @@ import { getAccountGallery } from '../../selectors';
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 { ScrollContainer } from 'react-router-scroll-4';
import LoadMore from '../../components/load_more';
const mapStateToProps = (state, props) => ({
medias: getAccountGallery(state, props.params.accountId),
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
@connect(mapStateToProps)
@@ -31,7 +30,6 @@ export default class AccountGallery extends ImmutablePureComponent {
medias: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
autoPlayGif: PropTypes.bool,
};
componentDidMount () {
@@ -67,7 +65,7 @@ export default class AccountGallery extends ImmutablePureComponent {
}
render () {
const { medias, autoPlayGif, isLoading, hasMore } = this.props;
const { medias, isLoading, hasMore } = this.props;
let loadMore = null;
@@ -100,7 +98,6 @@ export default class AccountGallery extends ImmutablePureComponent {
<MediaItem
key={media.get('id')}
media={media}
autoPlayGif={autoPlayGif}
/>
)}
{loadMore}

View File

@@ -10,10 +10,10 @@ export default class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
me: PropTypes.string.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
@@ -40,6 +40,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onReport(this.props.account);
}
handleReblogToggle = () => {
this.props.onReblogToggle(this.props.account);
}
handleMute = () => {
this.props.onMute(this.props.account);
}
@@ -61,7 +65,7 @@ export default class Header extends ImmutablePureComponent {
}
render () {
const { account, me } = this.props;
const { account } = this.props;
if (account === null) {
return <MissingIndicator />;
@@ -71,15 +75,14 @@ export default class Header extends ImmutablePureComponent {
<div className='account-timeline__header'>
<InnerHeader
account={account}
me={me}
onFollow={this.handleFollow}
/>
<ActionBar
account={account}
me={me}
onBlock={this.handleBlock}
onMention={this.handleMention}
onReblogToggle={this.handleReblogToggle}
onReport={this.handleReport}
onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain}

View File

@@ -15,6 +15,7 @@ import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal';
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { unfollowModal } from '../../../initial_state';
const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@@ -27,8 +28,6 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
me: state.getIn(['meta', 'me']),
unfollowModal: state.getIn(['meta', 'unfollow_modal']),
});
return mapStateToProps;
@@ -38,7 +37,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
if (this.unfollowModal) {
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.unfollowConfirm),
@@ -68,6 +67,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(mentionCompose(account, router));
},
onReblogToggle (account) {
if (account.getIn(['relationship', 'following', 'reblogs'])) {
dispatch(followAccount(account.get('id'), false));
} else {
dispatch(followAccount(account.get('id'), true));
}
},
onReport (account) {
dispatch(initReport(account));
},

View File

@@ -16,7 +16,6 @@ const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
me: state.getIn(['meta', 'me']),
});
@connect(mapStateToProps)
@@ -28,7 +27,6 @@ export default class AccountTimeline extends ImmutablePureComponent {
statusIds: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
me: PropTypes.string.isRequired,
};
componentWillMount () {
@@ -50,7 +48,7 @@ export default class AccountTimeline extends ImmutablePureComponent {
}
render () {
const { statusIds, isLoading, hasMore, me } = this.props;
const { statusIds, isLoading, hasMore } = this.props;
if (!statusIds && isLoading) {
return (
@@ -70,7 +68,6 @@ export default class AccountTimeline extends ImmutablePureComponent {
statusIds={statusIds}
isLoading={isLoading}
hasMore={hasMore}
me={me}
onScrollToBottom={this.handleScrollToBottom}
/>
</Column>

View File

@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import LoadingIndicator from '../../components/loading_indicator';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import AccountContainer from '../../containers/account_container';

View File

@@ -45,7 +45,6 @@ export default class ComposeForm extends ImmutablePureComponent {
preselectDate: PropTypes.instanceOf(Date),
is_submitting: PropTypes.bool,
is_uploading: PropTypes.bool,
me: PropTypes.string,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
@@ -164,6 +163,8 @@ export default class ComposeForm extends ImmutablePureComponent {
let publishText = '';
let publishText2 = '';
let title = '';
let title2 = '';
const privacyIcons = {
none: '',
@@ -173,7 +174,10 @@ export default class ComposeForm extends ImmutablePureComponent {
direct: 'envelope',
};
title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
if (showSideArm) {
// Enhanced behavior with dual toot buttons
publishText = (
<span>
{
@@ -185,13 +189,15 @@ export default class ComposeForm extends ImmutablePureComponent {
</span>
);
title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
publishText2 = (
<i
className={`fa fa-${privacyIcons[secondaryVisibility]}`}
aria-label={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`}
aria-label={title2}
/>
);
} else {
// Original vanilla behavior - no icon if public or unlisted
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
} else {
@@ -256,6 +262,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<Button
className='compose-form__publish__side-arm'
text={publishText2}
title={title2}
onClick={this.handleSubmit2}
disabled={submitDisabled}
/> : ''
@@ -263,6 +270,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<Button
className='compose-form__publish__primary'
text={publishText}
title={title}
onClick={this.handleSubmit}
disabled={submitDisabled}
/>

View File

@@ -157,7 +157,6 @@ class EmojiPickerMenu extends React.PureComponent {
intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired,
autoPlay: PropTypes.bool,
};
static defaultProps = {
@@ -235,7 +234,7 @@ class EmojiPickerMenu extends React.PureComponent {
}
render () {
const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
@@ -250,7 +249,7 @@ class EmojiPickerMenu extends React.PureComponent {
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(custom_emojis, autoPlay)}
custom={buildCustomEmojis(custom_emojis)}
color=''
emoji=''
set='twitter'
@@ -284,7 +283,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
autoPlay: PropTypes.bool,
intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
@@ -346,7 +344,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
}
render () {
const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state;
@@ -366,7 +364,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {
loading={loading}
onClose={this.onHideDropdown}
onPick={onPickEmoji}
autoPlay={autoPlay}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}

View File

@@ -23,7 +23,6 @@ const mapStateToProps = state => ({
preselectDate: state.getIn(['compose', 'preselectDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
me: state.getIn(['compose', 'me']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
settings: state.get('local_settings'),
filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,

View File

@@ -46,7 +46,7 @@ const getFrequentlyUsedEmojis = createSelector([
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.sort((a, b) => {
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
@@ -61,7 +61,6 @@ const getCustomEmojis = createSelector([
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
autoPlay: state.getIn(['meta', 'auto_play_gif']),
skinTone: state.getIn(['settings', 'skinTone']),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});

View File

@@ -1,9 +1,10 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
import { me } from '../../../initial_state';
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
account: state.getIn(['accounts', me]),
};
};

View File

@@ -3,9 +3,10 @@ import { connect } from 'react-redux';
import Warning from '../components/warning';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { me } from '../../../initial_state';
const mapStateToProps = state => ({
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
});
const WarningWrapper = ({ needsLockWarning }) => {

View File

@@ -5,5 +5,5 @@ const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
export function countableText(inputText) {
return inputText
.replace(urlRegex, urlPlaceholder)
.replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '@$2');
.replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3');
};

View File

@@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import ColumnSettings from '../../community_timeline/components/column_settings';
import { changeSetting } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'direct']),
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeSetting(['direct', ...key], checked));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
refreshDirectTimeline,
expandDirectTimeline,
} from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from '../../actions/streaming';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
});
@connect(mapStateToProps)
@injectIntl
export default class DirectTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECT', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch } = this.props;
dispatch(refreshDirectTimeline());
this.disconnect = dispatch(connectDirectStream());
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(expandDirectTimeline());
}
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<ColumnHeader
icon='envelope'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
loadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
</Column>
);
}
}

View File

@@ -57,5 +57,21 @@ describe('emoji', () => {
it('does an emoji whose filename is irregular', () => {
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
});
it('avoid emojifying on invisible text', () => {
expect(emojify('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>'))
.toEqual('<a href="http://example.com/test%F0%9F%98%84"><span class="invisible">http://</span><span class="ellipsis">example.com/te</span><span class="invisible">st😄</span></a>');
expect(emojify('<span class="invisible">:luigi:</span>', { ':luigi:': { static_url: 'luigi.exe' } }))
.toEqual('<span class="invisible">:luigi:</span>');
});
it('avoid emojifying on invisible text with nested tags', () => {
expect(emojify('<span class="invisible">😄<span class="foo">bar</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="foo">bar</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
expect(emojify('<span class="invisible">😄<span class="invisible">😕</span>😴</span>😇'))
.toEqual('<span class="invisible">😄<span class="invisible">😕</span>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
expect(emojify('<span class="invisible">😄<br/>😴</span>😇'))
.toEqual('<span class="invisible">😄<br/>😴</span><img draggable="false" class="emojione" alt="😇" title=":innocent:" src="/emoji/1f607.svg" />');
});
});
});

View File

@@ -1,3 +1,4 @@
import { autoPlayGif } from '../../initial_state';
import unicodeMapping from './emoji_unicode_mapping_light';
import Trie from 'substring-trie';
@@ -5,13 +6,13 @@ const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
let allowAnimations = false;
const emojify = (str, customEmojis = {}) => {
let rtn = '';
const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
let rtn = '', tagChars = tagCharsWithEmojis, invisible = 0;
for (;;) {
let match, i = 0, tag;
while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) {
while (i < str.length && (tag = tagChars.indexOf(str[i])) === -1 && (invisible || !(match = trie.search(str.slice(i))))) {
i += str.codePointAt(i) < 65536 ? 1 : 2;
}
let rend, replacement = '';
@@ -27,7 +28,7 @@ const emojify = (str, customEmojis = {}) => {
// now got a replacee as ':shortname:'
// if you want additional emoji handler, add statements below which set replacement and return true.
if (shortname in customEmojis) {
const filename = allowAnimations ? customEmojis[shortname].url : customEmojis[shortname].static_url;
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
return true;
}
@@ -35,7 +36,26 @@ const emojify = (str, customEmojis = {}) => {
})()) rend = ++i;
} else if (tag >= 0) { // <, &
rend = str.indexOf('>;'[tag], i + 1) + 1;
if (!rend) break;
if (!rend) {
break;
}
if (tag === 0) {
if (invisible) {
if (str[i + 1] === '/') { // closing tag
if (!--invisible) {
tagChars = tagCharsWithEmojis;
}
} else if (str[rend - 2] !== '/') { // opening tag
invisible++;
}
} else {
if (str.startsWith('<span class="invisible">', i)) {
// avoid emojifying on invisible text
invisible = 1;
tagChars = tagCharsWithoutEmojis;
}
}
}
i = rend;
} else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
@@ -51,14 +71,12 @@ const emojify = (str, customEmojis = {}) => {
export default emojify;
export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => {
export const buildCustomEmojis = (customEmojis) => {
const emojis = [];
allowAnimations = overrideAllowAnimations;
customEmojis.forEach(emoji => {
const shortcode = emoji.get('shortcode');
const url = allowAnimations ? emoji.get('url') : emoji.get('static_url');
const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
const name = shortcode.replace(':', '');
emojis.push({

View File

@@ -64,14 +64,14 @@ Object.keys(emojiMap).forEach(key => {
Object.keys(emojiIndex.emojis).forEach(key => {
const { native } = emojiIndex.emojis[key];
const { short_names, search, unified } = emojiMartData.emojis[key];
let { short_names, search, unified } = emojiMartData.emojis[key];
if (short_names[0] !== key) {
throw new Error('The compresser expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.');
}
short_names.splice(0, 1); // first short name can be inferred from the key
short_names = short_names.slice(1); // first short name can be inferred from the key
const searchData = [native, short_names, search];
if (unicodeToUnifiedName(native) !== unified) {

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavourites } from '../../actions/interactions';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';

View File

@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import AccountAuthorizeContainer from './containers/account_authorize_container';

View File

@@ -8,7 +8,7 @@ import {
fetchFollowers,
expandFollowers,
} from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
import HeaderContainer from '../account_timeline/containers/header_container';

View File

@@ -8,7 +8,7 @@ import {
fetchFollowing,
expandFollowing,
} from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
import HeaderContainer from '../account_timeline/containers/header_container';

View File

@@ -8,6 +8,7 @@ import { openModal } from '../../actions/modal';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../../initial_state';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@@ -17,6 +18,7 @@ const messages = defineMessages({
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
@@ -30,7 +32,7 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
myAccount: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
});
@@ -40,7 +42,7 @@ export default class GettingStarted extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
me: ImmutablePropTypes.map.isRequired,
myAccount: ImmutablePropTypes.map.isRequired,
columns: ImmutablePropTypes.list,
multiColumn: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
@@ -56,7 +58,7 @@ export default class GettingStarted extends ImmutablePureComponent {
}
render () {
const { intl, me, columns, multiColumn } = this.props;
const { intl, myAccount, columns, multiColumn } = this.props;
let navItems = [];
@@ -78,18 +80,22 @@ export default class GettingStarted extends ImmutablePureComponent {
}
}
navItems = navItems.concat([
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]);
if (me.get('locked')) {
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
}
navItems = navItems.concat([
<ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
<ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]);
if (myAccount.get('locked')) {
navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
}
navItems = navItems.concat([
<ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
]);
return (

View File

@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import AccountContainer from '../../containers/account_container';

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchReblogs } from '../../actions/interactions';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';

View File

@@ -4,6 +4,7 @@ import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { me } from '../../../initial_state';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -36,7 +37,6 @@ export default class ActionBar extends React.PureComponent {
onReport: PropTypes.func,
onPin: PropTypes.func,
onEmbed: PropTypes.func,
me: PropTypes.string.isRequired,
intl: PropTypes.object.isRequired,
};
@@ -80,7 +80,7 @@ export default class ActionBar extends React.PureComponent {
}
render () {
const { status, me, intl } = this.props;
const { status, intl } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));

View File

@@ -25,7 +25,6 @@ export default class DetailedStatus extends ImmutablePureComponent {
settings: ImmutablePropTypes.map.isRequired,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool,
};
handleAccountClick = (e) => {
@@ -76,7 +75,6 @@ export default class DetailedStatus extends ImmutablePureComponent {
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenMedia={this.props.onOpenMedia}
autoPlayGif={this.props.autoPlayGif}
/>
);
mediaIcon = 'picture-o';

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator';
@@ -22,13 +23,15 @@ import {
import { deleteStatus } from '../../actions/statuses';
import { initReport } from '../../actions/reports';
import { makeGetStatus } from '../../selectors';
import { ScrollContainer } from 'react-router-scroll';
import { ScrollContainer } from 'react-router-scroll-4';
import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../../glitch/components/status/container';
import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import { boostModal, deleteModal } from '../../initial_state';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -43,10 +46,6 @@ const makeMapStateToProps = () => {
settings: state.get('local_settings'),
ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal']),
deleteModal: state.getIn(['meta', 'delete_modal']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
return mapStateToProps;
@@ -67,17 +66,21 @@ export default class Status extends ImmutablePureComponent {
settings: ImmutablePropTypes.map.isRequired,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
me: PropTypes.string,
boostModal: PropTypes.bool,
deleteModal: PropTypes.bool,
autoPlayGif: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
fullscreen: false,
};
componentWillMount () {
this.props.dispatch(fetchStatus(this.props.params.statusId));
}
componentDidMount () {
attachFullscreenListener(this.onFullScreenChange);
}
componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this._scrolledIntoView = false;
@@ -113,7 +116,7 @@ export default class Status extends ImmutablePureComponent {
if (status.get('reblogged')) {
this.props.dispatch(unreblog(status));
} else {
if (e.shiftKey || !this.props.boostModal) {
if (e.shiftKey || !boostModal) {
this.handleModalReblog(status);
} else {
this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
@@ -124,7 +127,7 @@ export default class Status extends ImmutablePureComponent {
handleDeleteClick = (status) => {
const { dispatch, intl } = this.props;
if (!this.props.deleteModal) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
@@ -259,9 +262,18 @@ export default class Status extends ImmutablePureComponent {
}
}
componentWillUnmount () {
detachFullscreenListener(this.onFullScreenChange);
}
onFullScreenChange = () => {
this.setState({ fullscreen: isFullscreen() });
}
render () {
let ancestors, descendants;
const { status, settings, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
const { status, settings, ancestorsIds, descendantsIds } = this.props;
const { fullscreen } = this.state;
if (status === null) {
return (
@@ -295,7 +307,7 @@ export default class Status extends ImmutablePureComponent {
<ColumnBackButton />
<ScrollContainer scrollKey='thread'>
<div className='scrollable detailed-status__wrapper' ref={this.setRef}>
<div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
{ancestors}
<HotKeys handlers={handlers}>
@@ -303,15 +315,12 @@ export default class Status extends ImmutablePureComponent {
<DetailedStatus
status={status}
settings={settings}
autoPlayGif={autoPlayGif}
me={me}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
/>
<ActionBar
status={status}
me={me}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}

View File

@@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components';
import detectPassiveEvents from 'detect-passive-events';
import { scrollRight } from '../../../scroll';
@@ -23,6 +23,7 @@ const componentMap = {
'PUBLIC': PublicTimeline,
'COMMUNITY': CommunityTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,
};

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
import Button from '../../../components/button';
import { closeModal } from '../../../actions/modal';
import { muteAccount } from '../../../actions/accounts';
@@ -80,12 +81,13 @@ export default class MuteModal extends React.PureComponent {
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
</p>
<p>
<div>
<label htmlFor='mute-modal__hide-notifications-checkbox'>
<FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
<input id='mute-modal__hide-notifications-checkbox' type='checkbox' checked={notifications} onChange={this.toggleNotifications} />
{' '}
<Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} />
</label>
</p>
</div>
</div>
<div className='mute-modal__action-bar'>

View File

@@ -14,6 +14,7 @@ import {
List as ImmutableList,
Map as ImmutableMap,
} from 'immutable';
import { me } from '../../../initial_state';
const noop = () => { };
@@ -43,11 +44,11 @@ PageOne.propTypes = {
domain: PropTypes.string.isRequired,
};
const PageTwo = ({ me }) => (
const PageTwo = ({ myAccount }) => (
<div className='onboarding-modal__page onboarding-modal__page-two'>
<div className='figure non-interactive'>
<div className='pseudo-drawer'>
<NavigationBar onClose={noop} account={me} />
<NavigationBar onClose={noop} account={myAccount} />
</div>
<ComposeForm
text='Awoo! #introductions'
@@ -73,10 +74,10 @@ const PageTwo = ({ me }) => (
);
PageTwo.propTypes = {
me: ImmutablePropTypes.map.isRequired,
myAccount: ImmutablePropTypes.map.isRequired,
};
const PageThree = ({ me }) => (
const PageThree = ({ myAccount }) => (
<div className='onboarding-modal__page onboarding-modal__page-three'>
<div className='figure non-interactive'>
<Search
@@ -88,7 +89,7 @@ const PageThree = ({ me }) => (
/>
<div className='pseudo-drawer'>
<NavigationBar onClose={noop} account={me} />
<NavigationBar onClose={noop} account={myAccount} />
</div>
</div>
@@ -98,7 +99,7 @@ const PageThree = ({ me }) => (
);
PageThree.propTypes = {
me: ImmutablePropTypes.map.isRequired,
myAccount: ImmutablePropTypes.map.isRequired,
};
const PageFour = ({ domain, intl }) => (
@@ -166,7 +167,7 @@ PageSix.propTypes = {
};
const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
myAccount: state.getIn(['accounts', me]),
admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
domain: state.getIn(['meta', 'domain']),
});
@@ -178,7 +179,7 @@ export default class OnboardingModal extends React.PureComponent {
static propTypes = {
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
me: ImmutablePropTypes.map.isRequired,
myAccount: ImmutablePropTypes.map.isRequired,
domain: PropTypes.string.isRequired,
admin: ImmutablePropTypes.map,
};
@@ -188,11 +189,11 @@ export default class OnboardingModal extends React.PureComponent {
};
componentWillMount() {
const { me, admin, domain, intl } = this.props;
const { myAccount, admin, domain, intl } = this.props;
this.pages = [
<PageOne acct={me.get('acct')} domain={domain} />,
<PageTwo me={me} />,
<PageThree me={me} />,
<PageOne acct={myAccount.get('acct')} domain={domain} />,
<PageTwo myAccount={myAccount} />,
<PageThree myAccount={myAccount} />,
<PageFour domain={domain} intl={intl} />,
<PageSix admin={admin} domain={domain} />,
];

View File

@@ -4,13 +4,13 @@ import { scrollTopTimeline } from '../../../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect';
import { debounce } from 'lodash';
import { me } from '../../../initial_state';
const makeGetStatusIds = () => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
(state) => state.get('statuses'),
(state) => state.getIn(['meta', 'me']),
], (columnSettings, statusIds, statuses, me) => {
], (columnSettings, statusIds, statuses) => {
const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
let regex = null;

View File

@@ -29,6 +29,7 @@ import {
Following,
Reblogs,
Favourites,
DirectTimeline,
HashtagTimeline,
Notifications,
FollowRequests,
@@ -39,18 +40,20 @@ import {
PinnedStatuses,
} from './util/async-components';
import { HotKeys } from 'react-hotkeys';
import { me } from '../../initial_state';
import { defineMessages, injectIntl } from 'react-intl';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import '../../../glitch/components/status';
const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
});
const mapStateToProps = state => ({
systemFontUi: state.getIn(['meta', 'system_font_ui']),
layout: state.getIn(['local_settings', 'layout']),
isWide: state.getIn(['local_settings', 'stretch']),
navbarUnder: state.getIn(['local_settings', 'navbar_under']),
me: state.getIn(['meta', 'me']),
isComposing: state.getIn(['compose', 'is_composing']),
hasComposingText: state.getIn(['compose', 'text']) !== '',
});
const keyMap = {
@@ -71,6 +74,7 @@ const keyMap = {
goToNotifications: 'g n',
goToLocal: 'g l',
goToFederated: 'g t',
goToDirect: 'g d',
goToStart: 'g s',
goToFavourites: 'g f',
goToPinned: 'g p',
@@ -80,6 +84,7 @@ const keyMap = {
};
@connect(mapStateToProps)
@injectIntl
@withRouter
export default class UI extends React.Component {
@@ -95,8 +100,9 @@ export default class UI extends React.Component {
systemFontUi: PropTypes.bool,
navbarUnder: PropTypes.bool,
isComposing: PropTypes.bool,
me: PropTypes.string,
hasComposingText: PropTypes.bool,
location: PropTypes.object,
intl: PropTypes.object.isRequired,
};
state = {
@@ -104,6 +110,17 @@ export default class UI extends React.Component {
draggingOver: false,
};
handleBeforeUnload = (e) => {
const { intl, isComposing, hasComposingText } = this.props;
if (isComposing && hasComposingText) {
// Setting returnValue to any string causes confirmation dialog.
// Many browsers no longer display this text to users,
// but we set user-friendly message for other browsers, e.g. Edge.
e.returnValue = intl.formatMessage(messages.beforeUnload);
}
}
handleResize = debounce(() => {
// The cached heights are no longer accurate, invalidate
this.props.dispatch(clearHeight());
@@ -178,6 +195,7 @@ export default class UI extends React.Component {
}
componentWillMount () {
window.addEventListener('beforeunload', this.handleBeforeUnload, false);
window.addEventListener('resize', this.handleResize, { passive: true });
document.addEventListener('dragenter', this.handleDragEnter, false);
document.addEventListener('dragover', this.handleDragOver, false);
@@ -220,6 +238,7 @@ export default class UI extends React.Component {
}
componentWillUnmount () {
window.removeEventListener('beforeunload', this.handleBeforeUnload);
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('dragenter', this.handleDragEnter);
document.removeEventListener('dragover', this.handleDragOver);
@@ -302,6 +321,10 @@ export default class UI extends React.Component {
this.context.router.history.push('/timelines/public');
}
handleHotkeyGoToDirect = () => {
this.context.router.history.push('/timelines/direct');
}
handleHotkeyGoToStart = () => {
this.context.router.history.push('/getting-started');
}
@@ -315,7 +338,7 @@ export default class UI extends React.Component {
}
handleHotkeyGoToProfile = () => {
this.context.router.history.push(`/accounts/${this.props.me}`);
this.context.router.history.push(`/accounts/${me}`);
}
handleHotkeyGoToBlocked = () => {
@@ -357,6 +380,7 @@ export default class UI extends React.Component {
goToNotifications: this.handleHotkeyGoToNotifications,
goToLocal: this.handleHotkeyGoToLocal,
goToFederated: this.handleHotkeyGoToFederated,
goToDirect: this.handleHotkeyGoToDirect,
goToStart: this.handleHotkeyGoToStart,
goToFavourites: this.handleHotkeyGoToFavourites,
goToPinned: this.handleHotkeyGoToPinned,
@@ -377,6 +401,7 @@ export default class UI extends React.Component {
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />

View File

@@ -26,6 +26,10 @@ export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}

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