Compare commits

..

298 Commits

Author SHA1 Message Date
kibigo!
79d898ae0a Merge upstream!! #64 <3 <3 2017-07-12 02:03:17 -07:00
kibigo!
bcf7ee48e9 Importing vanillin from upstream ;) ;) 2017-07-12 01:10:17 -07:00
kibigo!
297921fce5 Moved glitch files to their own location ;) 2017-07-12 01:02:51 -07:00
Eugen Rochko
056b5ed72f Improve UI of admin site settings (#4163) 2017-07-12 03:24:04 +02:00
DJ Sundog
74eff5456c First lame pass at adding optional git commit hash display on /about/… (#67)
* First lame pass at adding optional git commit hash display on /about/more page.

Currently, this is implemented by checking for the existence of a file called CURRENT_RELEASE in the home directory of the user running Mastodon. If the file exists, its contents are added.

I've modified my update process to include the following before precompiling assets:

git log -1 | head -n 1 | cut -d " " -f2 > ~/CURRENT_RELEASE

That puts the current commit hash into the file ~/CURRENT_RELEASE, but you figured that out because you're a smart cookie.

As I am quite sure this is a horrible methodology for implementing this, I look forward to any improvements you have to offer!

* Updated to handle instances that share a user - the CURRENT_RELEASE file now lives in the instance's base directory.

This also requires modifying the update hook to `git log -1 | head -n 1 | cut -d " " -f2 > CURRENT_RELEASE`
2017-07-11 20:32:16 -04:00
Eugen Rochko
1764c32b9e Fix #4067 - Do not make HTTP round-trip when resolving local URL (#4160) 2017-07-12 00:39:15 +02:00
Ondřej Hruška
60d27b4302 Fixed #66 disabling collapse collapses all toots 2017-07-11 20:48:31 +02:00
Naoki Kosaka
b21ab498f8 Fix media modal prev button behavior. (#4158) 2017-07-11 19:56:45 +02:00
nightpool
1c6c6b271c Revert "Wrap methods of ProcessFeedService::ProcessEntry in classes (#4151)" (#4157)
This reverts commit 425acecfdb.
2017-07-11 18:19:16 +02:00
Ondřej Hruška
08d19778d5 Fix extra clickable spaces for narrow screen 2017-07-11 17:53:50 +02:00
Eugen Rochko
e6c81a635b Fix #2848 - Rake task to redownload avatars/headers (#4156)
Can be filtered by a specific domain
Resolves #2292
2017-07-11 17:25:49 +02:00
Eugen Rochko
f93de3a516 Fix #3462 - Require authentication for search API (#4155)
This makes it consistent with /api/v1/accounts/search and
previous behaviour has been an oversight.
2017-07-11 17:08:26 +02:00
Eugen Rochko
e19eefe219 Redesign the landing page, mount public timeline on it (#4122)
* Redesign the landing page, mount public timeline on it

* Adjust the standalone mounted component to the lacking of router

* Adjust auth layout pages to new design

* Fix tests

* Standalone public timeline polling every 5 seconds

* Remove now obsolete translations

* Add responsive design for new landing page

* Address reviews

* Add floating clouds behind frontpage form

* Use access token from public page when available

* Fix mentions and hashtags links, cursor on status content in standalone mode

* Add footer link to source code

* Fix errors on pages that don't embed the component, use classnames

* Fix tests

* Change anonymous autoPlayGif default to false

* When gif autoplay is disabled, hover to play

* Add option to hide the timeline preview

* Slightly improve alt layout

* Add elephant friend to new frontpage

* Display "back to mastodon" in place of "login" when logged in on frontpage

* Change polling time to 3s
2017-07-11 15:27:59 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
8784bd79d0 Require stylesheets in common.js (#4152)
Require stylesheets in common.js because stylesheets are shared by the
entry points.
2017-07-11 15:15:42 +02:00
Yamagishi Kazutoshi
31366334cb Drawer tab according to column (#4135)
* Add notifications link to drawer

* Remove local and public timeline tab in drawer

* Add home
2017-07-11 14:36:27 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
425acecfdb Wrap methods of ProcessFeedService::ProcessEntry in classes (#4151)
ProcessFeedService::ProcessEntry had many methods, so wrap them in classes
representing activities.
2017-07-11 13:37:05 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
29f314a502 Remove redundant inclusion (#4150) 2017-07-11 11:55:48 +02:00
Sorin Davidoi
cc68d1945b refactor: Rewrite immutablejs import statements using destructuring (#4147) 2017-07-11 01:00:14 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
7bacdd718a Fix PrecomputeFeedService for filtered statuses (#4148) 2017-07-11 01:00:01 +02:00
m4sk1n
958fe0f7db i18n: @7a889a8 (pl) (#4144)
* i18n: @7a889a8 (pl)

* Update pl.yml
2017-07-10 19:42:37 +02:00
unarist
e670fa2af6 Update es5-ext to avoid CSP violation (#4145)
Since es5-ext used `new Function("...")`, it caused CSP violation unless "unsafe-eval" included. So this patch updates it to the version which fixes it.

Note that this package is used in polyfills, so loaded only if needed. I've encountered this issue on iOS9.

cf. medikoo/es5-ext@d3864493
2017-07-10 19:41:55 +02:00
Eugen Rochko
a3d93e8bbe Fix #4059 - Remove ModuleConcatenationPlugin (#4139)
It increased memory usage of Webpack 1.5x fold with little benefits
2017-07-10 18:46:46 +02:00
STJrInuyasha
7a889a8e12 Remote following success page (#4129)
* Added a success page to remote following
Includes follow-through links to web (the old redirect target) and back to the remote user's profile

* Use Account.new in spec instead of a fake with only id
(fixes spec)

* Fabricate(:account) over Account.new

* Remove self from the success text
(and all HTML with it)
2017-07-10 18:05:06 +02:00
m4sk1n
d081d4a422 i18n: @2b9721d (pl) (#4143) 2017-07-10 18:04:43 +02:00
Eugen Rochko
34ccc058fa Limit total subscribe retries to 10, but space them out more (#4142)
Since there is little point in retrying so often when a service is down
or does not exist anymore. Subscriptions are renewed 1 day before they
should expire, so retrying in 30 minutes, then 2 hours, then 12 hours
is fine. If even after that, the remote server does not work, there is
little sense in retrying more often than once a day

Also, uniqueness of the job should ensure that failed retries will
not result in multiple retries for the same endpoint when the next
resubscription cycle comes
2017-07-10 18:04:23 +02:00
m4sk1n
7f9a353b94 i18n: @63baab0 (pl) (#4141) 2017-07-10 18:04:06 +02:00
Yamagishi Kazutoshi
31490e0d6c Add Japanese translations (#4140)
* Add Japanese translations for #3879

* Add Japanese translations for #4033

* Add Japanese translations for #4136
2017-07-10 16:32:17 +02:00
Ondřej Hruška
9f7a5aac1e Toot context menu is now centered 2017-07-10 10:21:20 -04:00
Ondřej Hruška
945c5812d3 Added extra clickable area in status gutter 2017-07-10 09:29:20 -04:00
のら
ca45bd0361 Add Japanese translation of terms and flash (#4137) 2017-07-10 14:04:05 +02:00
Yamagishi Kazutoshi
63baab088d Fix regular expression for RFC 5646 (regression from #3604) (#4133) 2017-07-10 14:02:18 +02:00
Yamagishi Kazutoshi
2b9721d1b3 Add setting a always mark media as sensitive (#4136) 2017-07-10 14:00:32 +02:00
abcang
617208053c Rescue exceptions related to Goldfinger at FetchRemoteStatusService (#4138) 2017-07-10 13:59:29 +02:00
Ondřej Hruška
667b567606 Make avatar in compose navbar hover-to-play 2017-07-10 10:26:46 +02:00
Sadiq Saif
4aa6cd66fc Change default for auto_play_fit to false for a11y (#4132)
This is per issue #3876
2017-07-10 04:49:48 +02:00
Lynx Kotoura
1c6cbdd4e4 Fix duplication of tag in columns_area.js (#4131)
Deleted ">" just a typo.
2017-07-10 04:37:10 +02:00
Yamagishi Kazutoshi
f8212da329 Add attribute for default privacy to verify credentials (#4075)
* Add attribute for default privacy to verify credentials

* add raw_note

* source
2017-07-10 03:29:34 +02:00
Sorin Davidoi
4122a837fa fix(components/media_modal): Aspect ratio (#4128)
* fix(components/media_modal): Aspect ratio

* fix: Remove useless style
2017-07-09 18:49:07 +02:00
Nolan Lawson
5fa2dd6e65 Use babel-plugin-transform-react-inline-elements (#4109) 2017-07-09 17:34:05 +02:00
abcang
307f3e0dd7 Rescue exceptions related to Goldfinger (#4044)
* Rescue exceptions related to Goldfinger

* Exclude Goldfinger::SSLError
2017-07-09 17:33:21 +02:00
Sorin Davidoi
fc4c74660b Swipeable views (#4105)
* feat: Replace react-swipeable with react-swipeable-views

* fix: iOS 9
2017-07-09 15:02:26 +02:00
unarist
caf938562e Avoid async import if the component is previously loaded (#4127) 2017-07-09 14:52:03 +02:00
Ondřej Hruška
8e2b1f79e4 Small indent fix in components.scss 2017-07-09 14:00:13 +02:00
Ondřej Hruška
345290a905 Avatar in the detail column no longer autoplays 2017-07-09 07:54:33 -04:00
unarist
ce3a371eee Fix initial loading of pinned Notifications column (#4126) 2017-07-09 13:04:30 +02:00
m4sk1n
8781a8e203 i18n: minor change (pl) (#4124) 2017-07-09 12:17:00 +02:00
Sorin Davidoi
37c832cdf7 refactor: Make all reducers sync (#4125) 2017-07-09 12:16:08 +02:00
Ondřej Hruška
2fb78fefc6 Fix fullwidth media CSS bugs with NSFW video, and bad spoiler margin on static pages (#60)
* Fix fullwidth style not applied to NSFW video correctly

* Fix botched video .media-spoiler margin on static pages
2017-07-09 09:07:14 +02:00
Daniel Hunsaker
f68fa930ea [nanobox] Allow Full-size Uploads (#4123)
The Nginx configurations used by Nanobox previously neglected to increase the default upload size limit. This PR bumps that value up to the current Mastodon limit of 8MiB.
2017-07-09 02:52:36 +02:00
nullkal
007ab330e6 Use charlock_holmes instead of nkf at FetchLinkCardService (#4080)
* Specs for language detection

* Use CharlockHolmes instead of NKF

* Correct mistakes

* Correct style

* Set hint_enc instead of falling back and strip_tags

* Improve specs

* Add dependencies
2017-07-08 22:44:31 +02:00
abcang
794781d121 Change account link to admin account link on report page (#4119) 2017-07-08 18:35:08 +02:00
Sorin Davidoi
91cacb1e8f fix: Rerender Bundle on route change (#4120) 2017-07-08 18:34:55 +02:00
unarist
46f5d3a2e9 Fix first loading of notifications when the column is pinned (#4114) 2017-07-08 17:22:24 +02:00
Yamagishi Kazutoshi
76318f8830 Don't use preview when image size is unknown (#4113) 2017-07-08 17:21:59 +02:00
Yamagishi Kazutoshi
852bda3d32 Use srcSet only when know width (#4112) 2017-07-08 17:20:53 +02:00
Jeroen
0324f807f4 Update and improvement Dutch language strings (#4117)
* Update

* Update
2017-07-08 17:17:02 +02:00
Eugen Rochko
864e3f8d9c Replace OEmbed and initial state Rabl templates with serializers (#4110)
* Replace OEmbed Rabl template with serializer

* Replace initial state rabl with serializer
2017-07-08 14:51:05 +02:00
unarist
102466ac58 Fix JSON serialization of media_attachment (#4111) 2017-07-08 14:50:45 +02:00
Ondřej Hruška
dc2b8bdecd Added a toggle for full-width media previews 2017-07-08 06:46:12 -04:00
Ondřej Hruška
e3c2183c12 New design for visibility icons 2017-07-07 23:07:16 -04:00
Nolan Lawson
63b77f2320 Avoid using getBoundingClientRect to calculate height (#4001) 2017-07-08 01:57:22 +02:00
Sylvhem
8fecd80108 Various fixes in the French translation (#4107)
* Changement de « Changement de mot de passe » en « Sécurité »

* Suppression de « (Two-factor auth) »

Change la valeur de la chaîne « two_factor_authentication » de « Identification à deux facteurs (Two-factor auth) » à « Identification à deux facteurs ».
La traduction anglaise entre parathentèse était redondante et gênait la lecture.

Change the value of the "two_factor_authentication" from "Identification à deux facteurs (Two-factor auth)" to "Identification à deux facteurs".
The English translation in brackets was superflous and was getting in the way of the reader.

* Remplace « ' » par « ’ »

Retire de la traduction les apostrophes droites « ' » (U+0027) au profit des apostrophes typographiques « ’ » (U+2019).
En typographie française, les apostrophes typographiques sont utilisées à la place des apostrophes droites. La traduction était jusqu’ici incohérente et utilisait les deux.

Remove from the translation all the vertical apostrophes (U+0027) in favor of the curly ones (U+2019).
In French typography, typographic apostrophes are used instead of vertical ones. The translation was incoherent and used both.

* Remplace « ... » par « … »

Remplace les séries de trois points par le caractère dédié « … » (U+2026).

Replace all the series of three dots by the dedicated character "…" (U+2026).

* Mise à jour

Crée config/locales/activerecord.fr.yml, ajoute de nouvelles chaînes et met à jour certains textes.
Les compteurs de caractères pour le pseudonyme et la biographie devrait maintenant pouvoir fonctionner même quand l’interface est en français.

Create config/locales/activerecord.fr.yml, add new strings et update some textes.
The caracters counters for the username and the biography should now work even when the interface is in French.

* Remplace « A » par « À »

Remplace « A » par « À » aux endroits où le mot est mal orthographié.

Replace "A" by "À" when the wrong word is used.

* Ajout d’espaces insécables

Ajoute des espaces insécables suivant les régles nécessaires en typographie française.

Add non-breaking spaces following rules of French typography.

* Remplace « certain » par « certain·e »

Harmonise la traduction en remplaçant « certain » par sa forme épicène.

Harmonize the translation by replacing "certain" (sure) by its epicene form.

* Corrige un angliscisme

Remplace « adresse e-mail » par « adresse électronique ».

Replace "adresse e-mail" (e-mail address) by "adresse électronique" (electronic address).
2017-07-08 01:27:22 +02:00
kibigo!
86f8df7903 Fixed avis on static pages 2017-07-07 15:56:05 -07:00
Sorin Davidoi
348d6f5e75 Lazy load components (#3879)
* feat: Lazy-load routes

* feat: Lazy-load modals

* feat: Lazy-load columns

* refactor: Simplify Bundle API

* feat: Optimize bundles

* feat: Prevent flashing the waiting state

* feat: Preload commonly used bundles

* feat: Lazy load Compose reducers

* feat: Lazy load Notifications reducer

* refactor: Move all dynamic imports into one file

* fix: Minor bugs

* fix: Manually hydrate the lazy-loaded reducers

* refactor: Move all dynamic imports to async-components

* fix: Loading modal style

* refactor: Avoid converting the raw state for each lazy hydration

* refactor: Remove unused component

* refactor: Maintain modal name

* fix: Add as=script to preload link

* chore: Fix lint error

* fix(components/bundle): Check if timestamp is set when computing elapsed

* fix: Load compose reducers for the onboarding modal
2017-07-08 00:06:02 +02:00
Eugen Rochko
00df69bc89 Fix #4058 - Use a long-lived cookie to keep track of user-level sessions (#4091)
* Fix #4058 - Use a long-lived cookie to keep track of user-level sessions

* Fix tests, smooth migrate from previous session-based identifier
2017-07-07 23:25:15 +02:00
m4sk1n
7a549f830e i18n: improve consistency (pl) (#4104)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-07-07 20:01:17 +02:00
Nolan Lawson
3f82d8b979 Gracefully stop streaming server (#4103) 2017-07-07 20:01:00 +02:00
m4sk1n
9fe6cfca48 i18n: @18d3fa9 (pl) (#4101) 2017-07-07 17:35:47 +02:00
Eugen Rochko
ebd2dde688 Restore streaming API output format (#4100)
* Restore streaming API output format

Regression from #4090

* Remove whitespace
2017-07-07 16:56:52 +02:00
Eugen Rochko
6e1261f277 Fix notifications including wrong status in JSON (#4097) 2017-07-07 16:19:28 +02:00
Yamagishi Kazutoshi
91d548f7e6 Update webpack-dev-server to v2.5.1 (#4094) 2017-07-07 14:12:45 +02:00
Yamagishi Kazutoshi
76eda2fc21 Add recursive object support to API response (#4095) 2017-07-07 14:12:16 +02:00
Ondřej Hruška
d41cec90cf Added toot visibility icons and removed Boost btn changing icon 2017-07-07 06:31:11 -04:00
Ondřej Hruška
7859e6ad45 Fix back button to never go to a different website using history len check 2017-07-07 05:59:15 -04:00
Surinna Curtis
3464bb30f8 replies to local-only toots default to local-only, and fix some regex bugs 2017-07-06 22:30:51 -05:00
Eugen Rochko
1c1819a78a Fix feed author not being enforced in ProcessFeedService (#4092)
Ensure the only allowed author of top-level entries in feed is the person
the feed belongs to (a verified user). Ensure delete events only apply
if the deleted item belonged to that user.
2017-07-07 04:31:40 +02:00
Eugen Rochko
8b2cad5637 Refactor JSON templates to be generated with ActiveModelSerializers instead of Rabl (#4090) 2017-07-07 04:02:06 +02:00
のら
2d6128672f Togglable filter links (#4021)
* Togglable filter links

* Rename is_selected to selected?
2017-07-07 00:48:09 +02:00
Daniel Hunsaker
185b41beb4 [nanobox] Add Automated Backups (#4023)
This PR adds automatic backups to Nanobox instances. The database, Redis, and user files are backed up every day at 03:00 (server time) to the data warehouse component which comes with every Nanobox app. Old backups are automatically cleared out, but the number of backups that are left untouched can be configured by setting the `BACKUP_COUNT` environment variable to any integer value greater than 0 (the default is 1).

Also updated `.env.nanobox` to reflect the current `.env.production.sample`.
2017-07-07 00:46:45 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
2083000027 Set default From address in config (#3756)
The old implementation sets default From address in mailers. It sets
the address from SMTP_FROM_ADDRESS, or notifications@localhost. The
behavior is occasionally undesired results.

In production environment, notifications@localhost is likely to be
incorrect.
In testing environment, the email address should not be varied by a
environment variable.

After appling this change,

In production environment, it will throw an exception when launching
Mastodon.
In testing environment, the address will be fixed with
notifications@localhost.
2017-07-07 00:12:12 +02:00
Ondřej Hruška
d87d70e89a Fixed js type error in advanced_options_dropdown.js 2017-07-06 14:46:45 -07:00
Damien Erambert
18d3fa953b Add a setting allowing the use of system's default font in Web UI (#4033)
* add a system_font_ui setting on the server

* Plug the system_font_ui on the front-end

* add EN/FR locales for the new setting

* put Roboto after all other fonts

* remove trailing whitespace so CodeClimate is happy

* fix user_spec.rb

* correctly write user_spect this time

* slightly better way of adding the classes

* add comments to the system-font stack for clarification

* use .system-font for the class instead

* don't use multiple lines for comments

* remove trailing whitespace

* use the classnames module for consistency

* use `mastodon-font-sans-serif` instead of Roboto directly
2017-07-06 22:39:56 +02:00
abcang
f76e71825d Improve Activity stream spoiler (#4088) 2017-07-06 21:31:03 +02:00
STJrInuyasha
6bf6d35637 Parse links in status content on update as well as mount (#4042)
* Update links in status content on update as well as mount
Fixes occasional bugs with mentions and hashtags not being set to open in a new column like they should, and instead opening in a new page

* use classList instead of raw className
2017-07-06 21:30:37 +02:00
unarist
9c03fd9cae Unobserve status on unmount (#4013)
This fixes a warning on status unmounting (e.g. deletion).

This also resets IntersectionObserverWrapper on disconnect to avoid `unobserve()` calls
which has bug in Edge.
2017-07-06 21:26:21 +02:00
Mantas
34c8a46d7d Remove ugly blue highlight on Android browsers (#4031) 2017-07-06 21:26:07 +02:00
Quent-in
26949607d2 l10n Occitan locale (#4089)
* Small adjustments

About the report part.

* Update time format
2017-07-06 21:10:12 +02:00
Shin Kojima
e7c0d87d98 Fix embedded SVG fill attribute (#4086)
* Fix embedded SVG fill attribute

SCSS darken/lighten functions may not return a color value, but a color
name like "white".  See following example:

https://www.sassmeister.com/gist/c41da93b87d536890ddf30a1f42e7816

This patch will normalize $color argument to FFFFFF style.

I also changed the function name from "url-friendly-colour" to
"hex-color", Because...

1. The name "url-friendly" is not meaningful enough to describe what it
   does.

2. It is familier to me using "color" rather than "colour"

    kojima:kojiMac mastodon[master]$ git grep -l colour
    app/javascript/styles/boost.scss
    spec/fixtures/files/attachment.jpg

    kojima:kojiMac mastodon[master]$ git grep -l color
    .rspec
    .scss-lint.yml
    Gemfile.lock
    app/javascript/mastodon/features/status/components/action_bar.js
    app/javascript/styles/about.scss
    app/javascript/styles/accounts.scss
    app/javascript/styles/admin.scss
    app/javascript/styles/basics.scss
    app/javascript/styles/boost.scss
    app/javascript/styles/compact_header.scss
    app/javascript/styles/components.scss
    app/javascript/styles/containers.scss
    app/javascript/styles/footer.scss
    app/javascript/styles/forms.scss
    app/javascript/styles/landing_strip.scss
    app/javascript/styles/reset.scss
    app/javascript/styles/stream_entries.scss
    app/javascript/styles/tables.scss
    app/javascript/styles/variables.scss
    app/views/admin/subscriptions/_subscription.html.haml
    app/views/layouts/application.html.haml
    app/views/layouts/error.html.haml
    app/views/manifests/show.json.rabl
    bin/webpack-dev-server
    config/initializers/httplog.rb
    public/500.html
    public/emoji/1f1e6-1f1e8.svg
    public/emoji/1f1ec-1f1f8.svg
    public/emoji/1f1f3-1f1ee.svg
    public/emoji/1f1fb-1f1ec.svg
    spec/fixtures/requests/idn.txt
    yarn.lock

* Add semicolon
2017-07-06 15:27:02 +02:00
m4sk1n
6d106d3943 i18n: minor changes in Polish translation (#4087)
* i18n: minor changes in Polish translation

* Update pl.json
2017-07-06 15:25:27 +02:00
kibigo!
0c7ee5c792 Fixed non-status notification styling 2017-07-05 19:26:19 -07:00
kibigo!
bba75c15f1 Statuses redux!
- Better unified reblogs, statuses, and notifications
- Polished up collapsed toots greatly
- Apologies to bea if this makes everything more difficult
2017-07-05 18:51:23 -07:00
Akihiko Odaki (@fn_aki@pawoo.net)
a37cf9548c Explicitly require MIME::Types (#4083) 2017-07-05 23:58:03 +02:00
abcang
5e6acf9601 Fix Nokogiri::HTML at FetchLinkCardService (#4072) 2017-07-05 14:54:21 +02:00
unarist
b52a5e6bd6 Show LoadMore button on Notifications even if all items are filtered (#4077) 2017-07-05 14:51:53 +02:00
Yamagishi Kazutoshi
bb194ddb3c Format datetime of subscriptions on admin UI (#4078) 2017-07-05 14:51:28 +02:00
Ondřej Hruška
4cbbea5881 Improved CSS for drawer to restore original looks (full height) 2017-07-05 07:21:36 -04:00
Ondřej Hruška
167c392efd Fix drawer clipping dropdowns 2017-07-05 07:21:36 -04:00
Surinna Curtis
193f354d3e a real default for advanced options long description 2017-07-05 00:09:20 -05:00
Surinna Curtis
6b67b91eb1 satisfy eslint 2017-07-04 21:33:53 -07:00
Surinna Curtis
6b77424660 some adjustments to open/active for advanced options dropdown 2017-07-04 21:33:53 -07:00
Surinna Curtis
301c185878 highlight … button if any options enabled 2017-07-04 21:33:53 -07:00
Surinna Curtis
cb7f54891f Revert "change active/hover display on advanced options"
This reverts commit ade773cb0a.
2017-07-04 21:33:53 -07:00
Surinna Curtis
f6ce1a9592 toggles for advanced options 2017-07-04 21:33:53 -07:00
Surinna Curtis
aee64b996c change active/hover display on advanced options 2017-07-04 21:33:53 -07:00
Surinna Curtis
0c71c0ccc8 reset advanced options when appropriate 2017-07-04 21:33:53 -07:00
Surinna Curtis
49e82c1e0f add an eye when submitting a toot with do_not_federate enabled 2017-07-04 21:33:53 -07:00
Surinna Curtis
556cede00f Local-only option and dropdown all working 2017-07-04 21:33:53 -07:00
Surinna Curtis
b73ee36949 Reduce advanced options dropdown width 2017-07-04 21:33:53 -07:00
Surinna Curtis
dd49c10cdb Further improvements to dropdown html 2017-07-04 21:33:53 -07:00
Surinna Curtis
85d5249479 The beginnings of an advanced options dropdown 2017-07-04 21:33:53 -07:00
m4sk1n
a38b34c37a i18n: Updated Polish translation (#4068)
* i18n: Updated Polish translation

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

* fuggin nano
2017-07-04 23:34:00 +02:00
Gyuhwan Park
1921ab40ea i18n: Update korean translation (#4066)
* Added Korean Translation (based on japanese)

* Update korean translation

* Update korean translation: fix syntax error

* Updated korean translation

* Update korean translation

* Update ko.json

Translate non-translated parts

* Update ko.yml

Translated missed parts - and fixed some typos

* Create simple_form.ko.yml

* Updated korean translation

* i18n: fix test fails

* Updated korean translation
2017-07-04 17:09:17 +02:00
Minori Hiraoka
976c18aa5f Fix Korean translation (#4065)
* Added Korean Translation (based on japanese)

* Update korean translation

* Update korean translation: fix syntax error

* Updated korean translation

* Update korean translation

* Update ko.json

Translate non-translated parts

* Update ko.yml

Translated missed parts - and fixed some typos

* Create simple_form.ko.yml

* Update simple_form.ko.yml

Translation error fix - password change form

* Update simple_form.ko.yml

* Update ko.json

Missing translation
2017-07-04 16:48:22 +02:00
Gyuhwan Park
4cddef1cea i18n: Add korean translation (#4064)
* Added Korean Translation (based on japanese)

* Update korean translation

* Update korean translation: fix syntax error

* Updated korean translation

* Update korean translation

* Update ko.json

Translate non-translated parts

* Update ko.yml

Translated missed parts - and fixed some typos

* Create simple_form.ko.yml

* Updated korean translation

* i18n: fix test fails
2017-07-04 16:11:23 +02:00
Yamagishi Kazutoshi
cbe94b88e2 Change webpack-dev-server repository (#4061) 2017-07-04 15:19:54 +02:00
Yamagishi Kazutoshi
275c5b51ed Customizable privacy policy from admin interface (#4062) 2017-07-04 15:19:24 +02:00
Surinna Curtis
ff9f2088f7 Move layout override into app settings modal
Squashed commit of the following:

commit 3842f879865818a3299f8283f8ed1b43c5566500
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 19:57:39 2017 -0500

    Fix some style issues

commit 08628a0234392ecac90e869a1272f429de0b6db2
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 19:35:22 2017 -0500

    Improved styling and layout of select app settings

commit 1787a7c20f2bf7101c6d6830450564178314a737
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 17:13:53 2017 -0500

    remove the layout selector ui from the drawer

commit 7d93b180b1e615e2c36210ad6f119fe80a2778d8
Author: Surinna Curtis <ekiru.0@gmail.com>
Date:   Mon Jul 3 17:01:51 2017 -0500

    Add layout setting to app settings modal
2017-07-03 21:51:00 -05:00
Akihiko Odaki (@fn_aki@pawoo.net)
f85dbe83c8 Remove sort in Feed (#4050)
In from_redis method, statuses retrieved from the database was mapped to
the IDs retrieved from Redis. It was equivalent to order from high to low
because those IDs are sorted in the same order.
Statuses are ordered with the ID by default, so we do not have to reorder.
Sorting statuses in the database is even faster since the IDs are indexed
with B-tree.
2017-07-03 13:17:27 +02:00
Yamagishi Kazutoshi
a9c326b200 Upgrade chai-enzyme, precss and uws (#4010)
* Update chai-enzyme to v0.8.0

* Upgrade precss to v2.0.0

* Upgrade uws to v8.14.0
2017-07-03 11:56:58 +02:00
Yamagishi Kazutoshi
92f1c474f3 Add fa-fw class to user agent icon (#4047) 2017-07-03 11:04:35 +02:00
abcang
a6d02cff36 Rescue exceptions caused by FetchLinkCardService (#4045) 2017-07-03 11:03:34 +02:00
のら
be94f9e35d Update Japanese translation (#4051) 2017-07-03 11:02:59 +02:00
Nolan Lawson
e282580101 Faster emojify() by avoiding str.replace() entirely (#4049) 2017-07-03 11:02:36 +02:00
Ratmir Karabut
331f0953e9 Update Russian translation (sessions) (#4041)
* 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)
2017-07-03 01:30:22 +02:00
Damien Erambert
133b892e0d Update French locales (#4034)
* add missing locales for French translation

* accent "Media" in the front-end locales

* images => médias

* Change 'rapport' to 'signalement' in French locales to be more coherent

* fix typo

* remove duplicate EN locale

* translate missing locales

* update missing locale

* fix typo

* unify with "utilisateur⋅ice⋅s"

* address PR comments
2017-07-02 18:36:35 +02:00
Sorin Davidoi
60da49f856 fix(components/columns_area): Increase delta for swipe detection (#4037) 2017-07-02 15:55:50 +02:00
unarist
d1d94216d1 Update Japanese translation (Credentials -> Security) (#4025) 2017-07-01 21:18:38 +02:00
Eugen Rochko
bf50e3e5ae Fix height issue in report modal 2017-07-01 14:50:10 +02:00
adbelle
15227c713d Remove color lightening for search results section
This will cause it to match the shading of the container behind it.
2017-06-30 18:38:36 -04:00
adbelle
30736f4886 Add opaque background to search results section 2017-06-30 18:38:36 -04:00
kibigo!
c58877862d createBio function added 2017-06-30 15:03:31 -07:00
beatrix
0e310f1ee3 put travis thing in readme.md 2017-06-30 12:36:10 -04:00
beatrix-bitrot
7dd4d9de96 try fix failing controller spec caused by long bios 2017-06-30 16:27:52 +00:00
Nolan Lawson
a978b88997 Faster emojify() algorithm, avoid regex replace (#4019)
* Faster emojify() algorithm, avoid regex replace

* add semicolon
2017-06-30 17:29:22 +02:00
Matt Jankowski
6dd5eac7fc Add controller spec for manifests controller (#4003) 2017-06-30 13:43:34 +02:00
Nolan Lawson
968354923e Fix webpack-dev-server on Windows (#4000)
* Fix webpack-dev-server on Windows

* Serve webpack from 0.0.0.0, access at 127.0.0.1
2017-06-30 13:43:26 +02:00
Matt Jankowski
59ddf81a45 Version bumps for gems (#4002)
* Update aws-sdk to version 2.10.4

* Update bootsnap to version 1.1.1

* Update capistrano to version 3.8.2

* Update capybara to version 2.14.4

* Update cld3 to version 3.1.3

* Update http_accept_language to version 2.1.1

* Update sidekiq to version 5.0.3

* Update rspec-sidekiq to version 3.0.3

* Update sidekiq-scheduler to version 2.1.7

* Update oj to version 3.2.0

* Update openssl to version 2.0.4

* Update pg to version 0.21.0

* Update twitter-text to version 1.14.6

* Update unicode-display_width to version 1.3.0

* Update scss_lint to version 0.54.0

* Update hamlit to version 2.8.4

* Update erubi to version 1.6.1

* Update httplog to version 0.99.4

* Update aws-sdk to version 2.10.6
2017-06-30 13:42:04 +02:00
Yamagishi Kazutoshi
3a7106f05a Fix that AdminMailer does not send (#4012) 2017-06-30 13:40:43 +02:00
Yamagishi Kazutoshi
5c7a4f0b32 Remove babel-cli (#4011) 2017-06-30 13:40:14 +02:00
Yamagishi Kazutoshi
0e09048537 Fix broken style in media gallery (regression from #3963) (#4014) 2017-06-30 13:40:00 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
7362469d89 Do not raise an error if PrecomputeFeed could not find any status (#4015) 2017-06-30 13:39:42 +02:00
abcang
1273fbf86e Rescue Addressable::URI::InvalidURIError at Remotable (#4017) 2017-06-30 13:38:36 +02:00
kibigo!
46f83bb28b Styling fixes to media spoilers 2017-06-30 02:56:19 -07:00
kibigo!
ec2daae71c Media display improvements
- built in fullwidth styling
 - letterbox settings toggle
 - media no longer counts towards height when making toot-collapsing
measurements
2017-06-30 02:18:25 -07:00
kibigo!
b525caf40a Fixing an aspect of #32 which had previously escaped my attention 2017-06-29 23:39:57 -07:00
kibigo!
fc65b691df Images now behind CWs on static pages 2017-06-29 22:59:28 -07:00
kibigo!
651c3d643c Images behind CWs in detailed statuses too 2017-06-29 22:48:22 -07:00
Yamagishi Kazutoshi
a27879c0cf Replace state to /web when root path (#4009) 2017-06-30 05:37:41 +02:00
Naoki Kosaka
049cea30b0 Fix media-gallery, overflow is hidden. (#4008) 2017-06-30 05:37:17 +02:00
abcang
b342c81c17 rescue HTTP::ConnectionError (#3992) 2017-06-29 13:04:07 +02:00
Yamagishi Kazutoshi
ead14f5bf0 Upgrade jsdom to version 11.0.0 (#3994) 2017-06-29 13:03:03 +02:00
kibigo!
cc4cba8afd Improvements to image icon appearance on SHOW MORE 2017-06-29 01:43:15 -07:00
kibigo!
99889ea57d Tiny Status improvements
- Media toots are no longer auto-collapsed if the media is behind a CW
- Display names no longer appear clickable when a toot is collapsed
- Fixed #36 by adding reduplicating the media icon inside the SHOW
MORE/LESS
2017-06-28 23:23:05 -07:00
kibigo!
19690d3e33 Fixes #32 bug with revealing sensitive media 2017-06-28 22:57:30 -07:00
kibigo!
0b371da971 Fixed improper centering of columns-area 2017-06-28 22:18:22 -07:00
kibigo!
2d8ebdcc72 Initial store for local settings is now Immutable all the way down 2017-06-28 22:11:21 -07:00
kibigo!
595c6de32c Added App Setttings Modal 2017-06-28 22:00:54 -07:00
Akihiko Odaki (@fn_aki@pawoo.net)
0a53ca444a Cover Admin::AccountsController more (#3327) 2017-06-29 01:43:10 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
f79c10162e Use multiple pairs for zadd in PrecomputeFeedService (#3990) 2017-06-29 01:25:31 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
60b2b56d38 Reduce number of commands in FeedManager#trim (#3989) 2017-06-29 01:17:26 +02:00
kibigo!
6cbbdc805f Fixed Regexp.new syntax 2017-06-28 15:16:13 -07:00
Eugen Rochko
b6a19e7b89 Bump version to 1.4.7 2017-06-28 17:44:17 +02:00
Eugen Rochko
71bc75e6ac Do not fail to create access token if superapp was never created (#3986) 2017-06-28 17:43:48 +02:00
Yamagishi Kazutoshi
e4fee6c138 Add Japanese translations (#3985)
ref #3929, #3935, #3949, #3981
2017-06-28 16:45:21 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
7d8e3721ae Overwrite old statuses with reblogs in PrecomputeFeedService (#3984) 2017-06-28 14:50:23 +02:00
m4sk1n
fb421a1f46 i18n: added email to activerecord.pl.yml (#3981) 2017-06-28 14:07:53 +02:00
kibigo!
7b1d233f4f Linear gradient no longer extends under metadata on account pages 2017-06-28 01:10:28 -07:00
kibigo!
03f9648377 Improvements to static metadata styling, especially for mobile 2017-06-28 00:57:32 -07:00
kibigo!
6107e95404 Backend YAML Processing + Profile Metadata on Static Pages 2017-06-28 00:27:44 -07:00
kibigo!
36805a39db Moved reblog wrapper outside of <Status> 2017-06-27 18:34:30 -07:00
kibigo!
ab4632a41e Allow line-breaks in bio metadata 2017-06-27 16:20:35 -07:00
m4sk1n
2a9805b987 i18n: Minor fix in devise.pl.yml (#3978) 2017-06-27 23:14:02 +02:00
m4sk1n
126f929c39 i18n: Use instance name in email notifications instead of Mastodon (pl) (#3976)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-06-27 23:10:43 +02:00
beatrix-bitrot
ddafde942c Merge remote-tracking branch 'upstream/master' 2017-06-27 20:46:13 +00:00
m4sk1n
da42bfadb5 i18n: E-mail notifications to admins about new reports (pl) (#3975) 2017-06-27 22:21:35 +02:00
m4sk1n
6ad72728f6 i18n: Turn report screen into a modal (pl) (#3974) 2017-06-27 22:14:31 +02:00
Sorin Davidoi
64d9c016bd fix(components/status): Up & down jump due to content being added to the DOM (#3972) 2017-06-27 18:43:53 +02:00
Eugen Rochko
12e7c81dd8 Turn report screen into a modal (#3965) 2017-06-27 18:07:21 +02:00
kibigo!
e6300de142 Profile metadata improvements 2017-06-27 05:48:26 -07:00
Midgard
16d0aed403 Use instance name in email notifications instead of "Mastodon" (#3763)
* Use instance name in "password changed" mail

instead of "Mastodon".

Fixes tootsuite#2620.

* Use instance name in password reset mail

instead of "Mastodon".
2017-06-27 14:22:36 +02:00
Debanshu Kundu
da9317fa56 #1456 Added rake task to add a user. (#1482) 2017-06-27 14:18:53 +02:00
Sorin Davidoi
be92babd00 Responsive images in media gallery (#3963)
* feat(components/media_gallery): Responsive images

* fix(components/media_gallery): Link to image URL
2017-06-27 13:46:37 +02:00
Yamagishi Kazutoshi
e2dd576a1b Update dependencies for Node.js (#3967)
* Update @storybook/addon-actions to v3.1.6

* Update @storybook/react to v3.1.6

* Update babel-loader to v7.1.0

* Update babel-plugin-transform-react-remove-prop-types to v0.4.6

* Update enzyme to v2.9.1

* Update fsevents to v1.1.2

* Update intersection-observer to v0.3.2

* Update npmlog to v4.1.2

* Update pg to v6.4.0

* Update postcss-loader to v2.0.6

* Update rails-ujs to v5.1.2

* Update react to v15.6.1

* Update react-addons-shallow-compare to v15.6.0

* Update react-dom to v15.6.0

* Update react-notification to v6.7.1

* Update react-test-renderer to v15.6.1

* Update react-textarea-autosize to v5.0.7

* Update redux to v3.7.1

* Update resolve-url-loader to v2.1.0

* Update sass-loader to v6.0.6

* Update sinon to v2.3.5

* Update stringz to v0.2.2

* Update uuid to v3.1.0

* Update websocket.js to v0.1.12

* Update yargs to v8.0.2

* yarn upgrade
2017-06-27 13:46:11 +02:00
Yamagishi Kazutoshi
8f2c91568c Maintain aspect ratio for preview image (#3966) 2017-06-27 13:43:53 +02:00
Yamagishi Kazutoshi
98eaa2aa27 Update Rails to v5.1.2 (#3968) 2017-06-27 13:41:03 +02:00
Surinna Curtis
a6f5111c79 Eyes with variation-selector-16 now also do local-only 2017-06-26 20:51:11 -05:00
kibigo!
59503a88ae Disable account linking on collapsed toots too 2017-06-26 15:41:09 -07:00
kibigo!
5df7bc3a8b Disable links on collapsed toots 2017-06-26 15:22:03 -07:00
Eugen Rochko
42b8220632 Fix #1624 - Send e-mail notifications to admins about new reports (#3949) 2017-06-27 00:04:00 +02:00
kibigo!
c806fef865 Notifications collapsed by default 2017-06-26 14:28:43 -07:00
beatrix-bitrot
49ba78d6f8 fix account spec broken by change to 500char bios 2017-06-26 20:55:44 +00:00
ThibG
a91d968cab Raise an error if salmon request response is unsatisfactory (#3960) 2017-06-26 19:39:58 +02:00
m4sk1n
646de92781 i18n: Updated Polish translation (#3956)
* i18n: Updated Polish translation

* Update pl.yml
2017-06-26 17:18:45 +02:00
m4sk1n
ae2b722f55 i18n: Warning to look into the spam folder (pl) (#3955) 2017-06-26 17:10:54 +02:00
Surinna Curtis
7b53d4bbca Bio length on client side mostly doesn't count metadata 2017-06-26 09:34:31 -05:00
Surinna Curtis
4f36aad6e8 don't count bio metadata against bio length on server 2017-06-26 09:34:31 -05:00
Daniel Hunsaker
7aeb9168b0 Add .gitattributes file to avoid unwanted CRLF (#3954)
When Windows checks out files, it defaults to changing line endings to CRLF. If these files are then copied to a Linux system to be run, and the endings aren't changed at some point in that process, things break. This file forces git to use LF for all text files on all systems (except the request testing specfiles) to prevent issues everywhere.
2017-06-26 13:15:24 +02:00
Alda Marteau-Hardi
f53ed108b0 Translate pin/unpin and fix some inconsistencies in gender neutral strings (#3952) 2017-06-26 13:04:36 +02:00
Yamagishi Kazutoshi
285038972b Stop using Babel with streaming server (#3950) 2017-06-26 04:49:39 +02:00
kibigo!
56ca33a6d3 Time needs to be just a little wider to accomodate minutes 2017-06-25 19:38:58 -07:00
kibigo!
aeff898137 We want wrapping here, sorry 😥 2017-06-25 19:26:59 -07:00
kibigo!
b323e00bf3 Merge branch 'master' of https://github.com/glitch-soc/mastodon 2017-06-25 19:19:52 -07:00
kibigo!
a520b118e4 Expand toot by clicking on body [fixed!] 2017-06-25 19:19:45 -07:00
beatrix-bitrot
93fc8aa14c keyword muting and local only tooting WIP 2017-06-26 02:19:21 +00:00
beatrix-bitrot
c0a665865e update bio length to 500 2017-06-26 02:18:52 +00:00
kibigo!
38a1299975 Stick images inside of CWs 2017-06-25 19:15:03 -07:00
kibigo!
96e1f75679 Fixes to overflows wrt drawer/column 2017-06-25 18:05:28 -07:00
kibigo!
3a99552f0c Fixed bad drawer custom.css 2017-06-25 17:55:48 -07:00
kibigo!
22cc5c0dec Improvements to action bar positioning 2017-06-25 17:35:27 -07:00
Takuya Yoshida
e5563843a2 Re-fix errorMiddleware (#3922) 2017-06-26 01:46:15 +02:00
unarist
c972e1ee1f Ignore DB_NAME for development env on streaming as well as rails side (#3948) 2017-06-26 01:45:50 +02:00
Surinna Curtis
efa425206c Move status action bar left
This also resolves the issues where sometimes the timestamps wrap and also sits unpleasantly close to the … button.
2017-06-25 18:24:50 -05:00
kibigo!
e60f27d649 Clicking on a collapsed toot just uncollapses it. 2017-06-25 16:09:32 -07:00
kibigo!
6a50e73089 Auto-uncollapse when expanding CW 2017-06-25 15:52:55 -07:00
kibigo!
b1f9892e63 [custom.scss] More media height fixes 2017-06-25 15:29:23 -07:00
kibigo!
d6e3918d92 Disables CW [Show More/Less] links in collapsed toots 2017-06-25 15:25:19 -07:00
Eugen Rochko
5e8d037e27 Fix #3910 - Require OTP authentication to disable 2FA (#3935)
* Fix #3910 - Require OTP authentication to disable 2FA. Also, remove ability
to generate new OTP backup codes *after* initial backup codes were handed
out during activation

* Restore recovery code re-generation

* Improve display of some 2FA elements
2017-06-25 23:51:46 +02:00
Eugen Rochko
ed7dc1704d Bind web UI access tokens to sessions (#3940)
* Add overview of active sessions

* Better display of browser/platform name

* Improve how browser information is stored and displayed for sessions overview

* Fix test

* Fix #2347 - Bind web UI access token to session

When you logout, session also destroys the access token, so it's no longer
valid. If access token is destroyed some other way, the session is also
destroyed, requiring a re-login.

Fix #1681 - Add scheduler to remove revoked access tokens and grants

* Fix test
2017-06-25 23:51:32 +02:00
kibigo!
6909bbdc9e [custom.scss] max-height of media now a fixed value 2017-06-25 14:44:52 -07:00
amazedkoumei
436ce03772 fix unnecessary variable (#3947) 2017-06-25 23:29:22 +02:00
kibigo!
ddc6b85912 Color adjustment to media icons 2017-06-25 14:25:35 -07:00
kibigo!
4bc237fcfe Adds media icons to toots 2017-06-25 14:22:11 -07:00
kibigo!
efacfec3ed Media previews for collapsed toots 2017-06-25 13:57:52 -07:00
Eugen Rochko
d821aba002 Rename "Credentials" page to "Security" for clarity (#3941)
* Rename "Credentials" page to "Security" for clarity

* Change "security" icon from cog to lock
2017-06-25 22:13:02 +02:00
kibigo!
8ea779e59a Patching rebase errors 2017-06-25 12:51:31 -07:00
Sorin Davidoi
4ce1540094 fix(features/compose): Handle external changes to the textarea (#3632) 2017-06-25 21:43:27 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
67243bda31 Cover Auth::RegistrationsController more (#3353) 2017-06-25 21:42:55 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
8f991831b8 Cover Admin::DomainBlocksController more (#3329)
Also domain_block fabricator now sets unique domains
2017-06-25 21:42:36 +02:00
Shel Raphen
7eda83a36a Glitchsocification 2017-06-25 19:21:51 +00:00
amazedkoumei
87efa38721 more free pgconfig by .env (#3909)
* more free pgconfig for streaming by .env

* fix wrong default values

* database.yml read ENV as same as streaming server
2017-06-25 18:13:31 +02:00
Eugen Rochko
f7301bd5b9 Add overview of active sessions (#3929)
* Add overview of active sessions

* Better display of browser/platform name

* Improve how browser information is stored and displayed for sessions overview

* Fix test
2017-06-25 16:54:30 +02:00
PFM
099a3b4eac Fix "undefined" in className (#3939) 2017-06-25 16:02:56 +02:00
unarist
3d4e21f1ec Don't set ASSET_HOST on build:development (#3936)
Setting ASSET_HOST to `http://0.0.0.0:8080` makes urls in manifest.json to
be invalid, e.g. `http://0.0.0.0:8080/packs/application.js`.

Anyway, we don't need set this on build:development because assets would
be delivered from same origin in development (and w/o dev-server).
2017-06-25 12:52:42 +02:00
unarist
68dca26a5d Fix react-intl/locale-data import issue on production build (#3937)
Webpack seems to fail to import `react-intl/locale-data/*.js` if those
files has been proceed by babel, and this also breaks applying our translation.

Note that this won't be a problem on English locale, because react-intl
includes it as default and works fine without manually added locale-data.
Also this issue seems to only occurs on production build, but I'm not sure
about reason.
2017-06-25 12:49:53 +02:00
kibigo!
af178d0ba6 Removed no-longer-necessary custom style 2017-06-24 21:28:30 -07:00
Gô Shoemake
e4326b3f12 Merge pull request #26 from ekiru/feature/manual-column-layout-setting
User-controlled multi-column/single-column overrides
2017-06-24 20:44:43 -07:00
kibigo!
b8a5052d53 Better style handling at small sizes 2017-06-24 20:36:19 -07:00
kibigo!
7427680e75 Allowed little media rules 2017-06-24 20:14:58 -07:00
kibigo!
ca0d30c04b OKAY THIS WORKS THIS WORKS 2017-06-24 20:04:46 -07:00
kibigo!
da05cde721 Better settings handling with localSettings (new!) 2017-06-24 19:56:37 -07:00
kibigo!
4c37f629bc Don't change layout of static pages 2017-06-24 18:30:59 -07:00
kibigo!
ddba5d3b8c Use Redux store to keep track of layout 2017-06-24 18:30:30 -07:00
unarist
1fc096ec75 Fix elephant in onboarding modal being very small sized on small devices (#3932) 2017-06-24 23:18:32 +02:00
unarist
21c2bc119c Clean column collapsible (#3931)
* Remove unused column_collapsable.js
* Remove old styles
* Extract `> div`  style to independent class
2017-06-24 23:18:11 +02:00
Sorin Davidoi
d23293c762 feat(components/onboarding_modal): Swipe between pages (#3934) 2017-06-24 23:17:39 +02:00
Surinna Curtis
ceb545c080 Pass in correct "singleColumn" prop value when auto-columns is not used. 2017-06-24 15:29:46 -05:00
Surinna Curtis
a70468aa56 Support overriding media queries for deciding between single-column/multi-column layouts with a class 2017-06-24 15:29:46 -05:00
unarist
138e5a0b1e Fix webpack config for Windows (#3926) 2017-06-24 14:03:52 +02:00
Yamagishi Kazutoshi
79dacea962 Fix #3924 (regression from #3906) (#3925) 2017-06-24 12:24:02 +02:00
beatrix-bitrot
8b23bf7cbd clean up old avatar class 2017-06-24 03:51:01 +00:00
Matthew Walsh
f1a60d4b81 Unified avatar styling
Avatars now have consistent styling across all pages; border radius can be adjusted with a SASS variable ($ui-avatar-border-size)
2017-06-24 03:03:27 +00:00
kibigo!
2513d92c54 Un-hide dropdown menu ;P 2017-06-23 19:39:44 -07:00
kibigo!
414dfb3955 ESLint improvements for Profile Metadata 2017-06-23 18:43:30 -07:00
Gô Shoemake
67adbcc60c Reblog support for collapsed toots 2017-06-23 18:23:26 -07:00
unarist
4e6b5e7879 Use debounce for dispatch scrollTopNotification and expandNotifications (#3700) 2017-06-24 02:43:26 +02:00
beatrix-bitrot
453b9c6e7e missing punctuation 2017-06-23 22:01:04 +00:00
beatrix
d9b9bb8c5e glitch the getting started image 2017-06-23 21:50:45 +00:00
kibigo!
40ecbfd4a9 Very minor styling improvements to toot-collapsing 2017-06-23 21:50:45 +00:00
kibigo!
4fe45dda9a Updates height upon collapsing 2017-06-23 21:50:45 +00:00
kibigo!
4bd7482a7a Minor collapsing button improvements~ 2017-06-23 21:50:45 +00:00
kibigo!
93c52301ad Collapsable toots [1/??] 2017-06-23 21:50:45 +00:00
kibigo!
0d3ec19e89 Profile Metadata HACK 😈 2017-06-23 21:45:14 +00:00
Go Shoemake
62a75891ab Fixes drawer so stuff doesn't overflow 2017-06-23 21:45:14 +00:00
Charlotte Fields
b27842dc70 cybre cleanup 2017-06-23 21:45:14 +00:00
Chronister
39b6b37b74 cybrespace to 1.4.2 2017-06-23 21:45:14 +00:00
Chronister
65528fc54e All cybrespace changes through 5/28 2017-06-23 21:45:14 +00:00
Charlotte Fields
382572c213 adding cybre changes 2017-06-23 21:45:14 +00:00
beatrix-bitrot
9bc593d675 update local modifications for cors and cp 2017-06-23 21:45:14 +00:00
beatrix-bitrot
09f7ad3614 silly readme update to test automated deploys 2017-06-23 21:45:14 +00:00
beatrix-bitrot
7c2ea42cd5 update README.md 2017-06-23 21:45:14 +00:00
beatrix
ea785d0baf Update README.md 2017-06-23 21:45:14 +00:00
Beatrix Bitrot
a337c5dbe5 CORS tweaks 2017-06-23 21:45:14 +00:00
Daniel Hunsaker
c0979381a4 Fix a typo and give CW'd statuses the right cursor (#3918) 2017-06-23 23:13:27 +02:00
Eugen Rochko
676f577e7e Fix webpack-dev-server until it's fixed upstream (#3916) 2017-06-23 19:40:51 +02:00
Yamagishi Kazutoshi
c1a8e3d1eb Use Class and Property Decorators (#3730)
ref https://tc39.github.io/proposal-decorators/
2017-06-23 19:36:54 +02:00
Takuya Yoshida
0c44316b22 Fix errorMiddleware to prevent "TypeError: res.writeHead is not a function" (#3913)
* Fix errorMiddleware

* Add "eslint-disable-line no-unused-vars"
2017-06-23 19:22:02 +02:00
Sorin Davidoi
2211e8d1cd Revocable sessions (#3616)
* feat: Revocable sessions

* fix: Tests using sign_in

* feat: Configuration entry for the maximum number of session activations
2017-06-23 18:50:53 +02:00
Nolan Lawson
3783cadf2d Apply babel to react-intl to remove prop-types (#3914) 2017-06-23 18:21:33 +02:00
Eugen Rochko
a071047c13 Merge branch 'sorin-davidoi-swipe-gestures' 2017-06-23 17:52:56 +02:00
Eugen Rochko
281f07244b Merge branch 'swipe-gestures' of git://github.com/sorin-davidoi/mastodon into sorin-davidoi-swipe-gestures 2017-06-23 17:52:39 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
6f34a6a77f Add index statuses on account_id and id (#3895) 2017-06-23 17:46:00 +02:00
Nolan Lawson
e078919f07 Upgrade to Webpack 3 with module concatenation (#3912) 2017-06-23 17:44:55 +02:00
Eugen Rochko
7b13e6efc2 Bump version to 1.4.6 2017-06-23 17:02:14 +02:00
Eugen Rochko
3f59238207 Add important test for full-width hashtags (#3911) 2017-06-23 17:01:53 +02:00
Yamagishi Kazutoshi
eff9416469 Remove unused variables (#3906) 2017-06-23 16:05:04 +02:00
Yamagishi Kazutoshi
6fbb3841a6 Add prefix to setting toggle ID (#3907) 2017-06-23 13:55:05 +02:00
Sorin Davidoi
d8c4781377 fix: Apply :hover, :focus and :active only when multiple columns 2017-06-23 13:48:46 +02:00
Sorin Davidoi
bc6e958229 feat: Swipeable media 2017-06-23 13:48:46 +02:00
Sorin Davidoi
a6d8d1036a feat: Swipeable columns 2017-06-23 13:48:46 +02:00
Sorin Davidoi
3d403a013d chore(yarn): Install react-swipeable 2017-06-23 13:40:58 +02:00
Ratmir Karabut
9ca02a00a6 Update Russian translation (#3902)
* 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
2017-06-22 23:28:58 +02:00
nightpool
3e8e9c8ae4 Use the stable RVM installer (#3901)
as mentioned by ElvenSpellmaker here: https://github.com/rvm/rvm/issues/4068

Adds a workaround for the issue mentioned by @abcang here: https://github.com/tootsuite/mastodon/pull/3897#issuecomment-310436668 and makes sure that we're using the stable installer to install the stable version.
2017-06-22 23:28:52 +02:00
Charlotte Fields
7bc1805827 fixed vagrantfile (#3897) 2017-06-22 18:35:27 +02:00
Akihiko Odaki (@fn_aki@pawoo.net)
e27f792c24 Some minor change and spec for Account (#3813)
* Introduce domains method to Account relation

Account had followers_domains method, which was excessively specific.
Let relation of Account have domains method instead.

* Move follow_mapping in Account to AccountInteractions

* Introduce shared examples for AccountAvatar inclusion

* Cover Account more
2017-06-22 18:34:27 +02:00
Quent-in
98fab24bea Update of doorkeeper.oc.yml (#3896)
Just some adjustements
2017-06-22 13:39:13 +02:00
Eugen Rochko
f566c47dda Fix travis builds
https://github.com/travis-ci/travis-ci/issues/7941#issuecomment-310320597
2017-06-22 13:24:00 +02:00
Eugen Rochko
0190aac240 Fix regression from #3842 (#3892)
* Fix regression from #3842

Simplify the query by omitting all direct statuses. Private statuses
are allowed because they are from accounts we are following (so
by definition)

Resolves #3887 (alternative)

* Adjust test
2017-06-22 02:38:50 +02:00
unarist
cc382c5006 Don't attach IntersectionObserver for wrapped statuses (#3883)
(This patch has been merged as bugfix and reverted, but still valuable as
improvement)

Previously, we've attached IntersectionObserver twice for boosted statuses:
wrapper Status and wrapped Status. but wrapped Status don't need to manage
intersection and visibility by itself, because it's a part of wrapper Status.
2017-06-21 06:47:36 +02:00
518 changed files with 11951 additions and 4178 deletions

View File

@@ -15,6 +15,7 @@
"plugins": [
"syntax-dynamic-import",
["transform-object-rest-spread", { "useBuiltIns": true }],
"transform-decorators-legacy",
"transform-class-properties",
[
"react-intl",
@@ -43,6 +44,7 @@
]
}
],
"transform-react-inline-elements",
[
"transform-runtime",
{

View File

@@ -69,7 +69,7 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io
# PAPERCLIP_ROOT_URL=/system
# Optional asset host for multi-server setups
# CDN_HOST=assets.example.com
# CDN_HOST=https://assets.example.com
# S3 (optional)
# S3_ENABLED=true

View File

@@ -1,7 +1,9 @@
---
root: true
env:
browser: true
node: false
node: true
es6: true
parser: babel-eslint
@@ -52,8 +54,14 @@ rules:
no-mixed-spaces-and-tabs: warn
no-nested-ternary: warn
no-trailing-spaces: warn
no-undef: error
no-unreachable: error
no-unused-expressions: error
no-unused-vars:
- error
- vars: all
args: after-used
ignoreRestSiblings: true
object-curly-spacing:
- error
- always
@@ -81,7 +89,10 @@ rules:
- 2
react/jsx-no-bind: error
react/jsx-no-duplicate-props: error
react/jsx-no-undef: error
react/jsx-tag-spacing: error
react/jsx-uses-react: error
react/jsx-uses-vars: error
react/jsx-wrap-multilines: error
react/no-multi-comp: off
react/no-string-refs: error

14
.gitattributes vendored Normal file
View File

@@ -0,0 +1,14 @@
* text=auto eol=lf
*.eot -text
*.gif -text
*.gz -text
*.ico -text
*.jpg -text
*.mp3 -text
*.ogg -text
*.png -text
*.ttf -text
*.webm -text
*.woff -text
*.woff2 -text
spec/fixtures/requests/** -text !eol

View File

@@ -7,7 +7,7 @@ cache:
- public/assets
- public/packs-test
dist: trusty
sudo: false
sudo: required
notifications:
email: false
@@ -32,6 +32,7 @@ addons:
- g++-6
- libprotobuf-dev
- protobuf-compiler
- libicu-dev
rvm:
- 2.3.4

View File

@@ -3,3 +3,4 @@ libprotobuf-dev
ffmpeg
libxdamage1
libxfixes3
libicu-dev

View File

@@ -25,6 +25,7 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit
ffmpeg \
file \
git \
icu-dev \
imagemagick@edge \
libpq \
libxml2 \

View File

@@ -18,8 +18,11 @@ gem 'aws-sdk', '~> 2.9'
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.5'
gem 'bootsnap'
gem 'browser'
gem 'charlock_holmes', '~> 0.7.3'
gem 'cld3', '~> 3.1'
gem 'devise', '~> 4.2'
gem 'devise-two-factor', '~> 3.0'
@@ -34,6 +37,7 @@ gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 0.99'
gem 'kaminari', '~> 1.0'
gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.1'
gem 'nokogiri', '~> 1.7'
gem 'oj', '~> 3.0'
gem 'ostatus2', '~> 2.0'

View File

@@ -1,47 +1,52 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (5.1.1)
actionpack (= 5.1.1)
actioncable (5.1.2)
actionpack (= 5.1.2)
nio4r (~> 2.0)
websocket-driver (~> 0.6.1)
actionmailer (5.1.1)
actionpack (= 5.1.1)
actionview (= 5.1.1)
activejob (= 5.1.1)
actionmailer (5.1.2)
actionpack (= 5.1.2)
actionview (= 5.1.2)
activejob (= 5.1.2)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.1.1)
actionview (= 5.1.1)
activesupport (= 5.1.1)
actionpack (5.1.2)
actionview (= 5.1.2)
activesupport (= 5.1.2)
rack (~> 2.0)
rack-test (~> 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.1.1)
activesupport (= 5.1.1)
actionview (5.1.2)
activesupport (= 5.1.2)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
active_model_serializers (0.10.6)
actionpack (>= 4.1, < 6)
activemodel (>= 4.1, < 6)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.2)
active_record_query_trace (1.5.4)
activejob (5.1.1)
activesupport (= 5.1.1)
activejob (5.1.2)
activesupport (= 5.1.2)
globalid (>= 0.3.6)
activemodel (5.1.1)
activesupport (= 5.1.1)
activerecord (5.1.1)
activemodel (= 5.1.1)
activesupport (= 5.1.1)
activemodel (5.1.2)
activesupport (= 5.1.2)
activerecord (5.1.2)
activemodel (= 5.1.2)
activesupport (= 5.1.2)
arel (~> 8.0)
activesupport (5.1.1)
activesupport (5.1.2)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (~> 0.7)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.5.1)
public_suffix (~> 2.0, >= 2.0.2)
airbrussh (1.2.0)
airbrussh (1.3.0)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.2)
activerecord (>= 3.2, < 6.0)
@@ -52,13 +57,13 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-sdk (2.9.37)
aws-sdk-resources (= 2.9.37)
aws-sdk-core (2.9.37)
aws-sdk (2.10.6)
aws-sdk-resources (= 2.10.6)
aws-sdk-core (2.10.6)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-resources (2.9.37)
aws-sdk-core (= 2.9.37)
aws-sdk-resources (2.10.6)
aws-sdk-core (= 2.10.6)
aws-sigv4 (1.0.0)
bcrypt (3.1.11)
better_errors (2.1.1)
@@ -67,9 +72,10 @@ GEM
rack (>= 0.9.0)
binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1)
bootsnap (1.0.0)
bootsnap (1.1.1)
msgpack (~> 1.0)
brakeman (3.6.2)
browser (2.4.0)
builder (3.2.3)
bullet (5.5.1)
activesupport (>= 3.0.0)
@@ -77,7 +83,7 @@ GEM
bundler-audit (0.5.0)
bundler (~> 1.2)
thor (~> 0.18)
capistrano (3.8.1)
capistrano (3.8.2)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
@@ -93,15 +99,18 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (2.14.2)
capybara (2.14.4)
addressable
mime-types (>= 1.16)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (~> 2.0)
case_transform (0.2)
activesupport
charlock_holmes (0.7.3)
chunky_png (1.3.8)
cld3 (3.1.2)
cld3 (3.1.3)
ffi (>= 1.1.0, < 1.10.0)
climate_control (0.2.0)
cocaine (0.5.8)
@@ -141,9 +150,9 @@ GEM
thread
thread_safe
encryptor (3.0.0)
erubi (1.6.0)
erubi (1.6.1)
erubis (2.7.0)
et-orbi (1.0.4)
et-orbi (1.0.5)
tzinfo
execjs (2.7.0)
fabrication (2.16.1)
@@ -160,7 +169,7 @@ GEM
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
hamlit (2.8.1)
hamlit (2.8.4)
temple (>= 0.8.0)
thor
tilt
@@ -181,9 +190,9 @@ GEM
http-cookie (1.0.3)
domain_name (~> 0.5)
http-form_data (1.0.3)
http_accept_language (2.1.0)
http_accept_language (2.1.1)
http_parser.rb (0.6.0)
httplog (0.99.3)
httplog (0.99.4)
colorize
rack
i18n (0.8.4)
@@ -199,6 +208,7 @@ GEM
terminal-table (>= 1.5.1)
jmespath (1.3.1)
json (2.1.0)
jsonapi-renderer (0.1.2)
kaminari (1.0.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.0.1)
@@ -248,8 +258,8 @@ GEM
mini_portile2 (~> 2.2.0)
nokogumbo (1.4.13)
nokogiri
oj (3.1.0)
openssl (2.0.3)
oj (3.2.0)
openssl (2.0.4)
orm_adapter (0.5.0)
ostatus2 (2.0.1)
addressable (~> 2.4)
@@ -271,7 +281,7 @@ GEM
parallel
parser (2.4.0.0)
ast (~> 2.2)
pg (0.20.0)
pg (0.21.0)
pghero (1.7.0)
activerecord
pkg-config (1.2.3)
@@ -297,17 +307,17 @@ GEM
rack-test (0.6.3)
rack (>= 1.0)
rack-timeout (0.4.2)
rails (5.1.1)
actioncable (= 5.1.1)
actionmailer (= 5.1.1)
actionpack (= 5.1.1)
actionview (= 5.1.1)
activejob (= 5.1.1)
activemodel (= 5.1.1)
activerecord (= 5.1.1)
activesupport (= 5.1.1)
rails (5.1.2)
actioncable (= 5.1.2)
actionmailer (= 5.1.2)
actionpack (= 5.1.2)
actionview (= 5.1.2)
activejob (= 5.1.2)
activemodel (= 5.1.2)
activerecord (= 5.1.2)
activesupport (= 5.1.2)
bundler (>= 1.3.0, < 2.0)
railties (= 5.1.1)
railties (= 5.1.2)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.2)
actionpack (~> 5.x, >= 5.0.1)
@@ -323,9 +333,9 @@ GEM
railties (~> 5.0)
rails-settings-cached (0.6.5)
rails (>= 4.2.0)
railties (5.1.1)
actionpack (= 5.1.1)
activesupport (= 5.1.1)
railties (5.1.2)
actionpack (= 5.1.2)
activesupport (= 5.1.2)
method_source
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
@@ -373,7 +383,7 @@ GEM
rspec-expectations (~> 3.6.0)
rspec-mocks (~> 3.6.0)
rspec-support (~> 3.6.0)
rspec-sidekiq (3.0.1)
rspec-sidekiq (3.0.3)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.6.0)
@@ -394,10 +404,10 @@ GEM
nokogiri (>= 1.4.4)
nokogumbo (~> 1.4.1)
sass (3.4.24)
scss_lint (0.53.0)
scss_lint (0.54.0)
rake (>= 0.9, < 13)
sass (~> 3.4.20)
sidekiq (5.0.2)
sidekiq (5.0.3)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0)
@@ -405,7 +415,7 @@ GEM
sidekiq-bulk (0.1.1)
activesupport
sidekiq
sidekiq-scheduler (2.1.5)
sidekiq-scheduler (2.1.7)
redis (~> 3)
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
@@ -442,7 +452,7 @@ GEM
thread (0.2.2)
thread_safe (0.3.6)
tilt (2.0.7)
twitter-text (1.14.5)
twitter-text (1.14.6)
unf (~> 0.1.0)
tzinfo (1.2.3)
thread_safe (~> 0.1)
@@ -453,7 +463,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.7.4)
unicode-display_width (1.2.1)
unicode-display_width (1.3.0)
uniform_notifier (1.10.0)
warden (1.2.7)
rack (>= 1.0)
@@ -475,6 +485,7 @@ PLATFORMS
ruby
DEPENDENCIES
active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.5)
addressable (~> 2.5)
annotate (~> 2.7)
@@ -483,6 +494,7 @@ DEPENDENCIES
binding_of_caller (~> 0.7)
bootsnap
brakeman (~> 3.6)
browser
bullet (~> 5.5)
bundler-audit (~> 0.5)
capistrano (~> 3.8)
@@ -490,6 +502,7 @@ DEPENDENCIES
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 2.14)
charlock_holmes (~> 0.7.3)
cld3 (~> 3.1)
climate_control (~> 0.2)
devise (~> 4.2)
@@ -514,6 +527,7 @@ DEPENDENCIES
link_header (~> 0.0)
lograge (~> 0.5)
microformats2 (~> 3.0)
mime-types (~> 3.1)
nokogiri (~> 1.7)
oj (~> 3.0)
ostatus2 (~> 2.0)

View File

@@ -2,4 +2,6 @@ Mastodon Glitch Edition
========
Now with automated deploys!
[![Build Status](https://travis-ci.org/glitch-soc/mastodon.svg?branch=master)](https://travis-ci.org/glitch-soc/mastodon)
So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it?

6
Vagrantfile vendored
View File

@@ -37,14 +37,18 @@ sudo apt-get install \
yarn \
libprotobuf-dev \
libreadline-dev \
libicu-dev \
-y
# Install rvm
read RUBY_VERSION < .ruby-version
gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3
curl -sSL https://get.rvm.io | bash -s stable --ruby=$RUBY_VERSION
curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION
source /home/vagrant/.rvm/scripts/rvm
# Install Ruby
rvm install ruby-$RUBY_VERSION
# Configure database
sudo -u postgres createuser -U postgres vagrant -s
sudo -u postgres createdb -U postgres mastodon_development

View File

@@ -2,9 +2,12 @@
class AboutController < ApplicationController
before_action :set_body_classes
before_action :set_instance_presenter, only: [:show, :more]
before_action :set_instance_presenter, only: [:show, :more, :terms]
def show; end
def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
def more; end
@@ -15,6 +18,7 @@ class AboutController < ApplicationController
def new_user
User.new.tap(&:build_account)
end
helper_method :new_user
def set_instance_presenter
@@ -24,4 +28,11 @@ class AboutController < ApplicationController
def set_body_classes
@body_classes = 'about-body'
end
def initial_state_params
{
settings: {},
token: current_session&.token,
}
end
end

View File

@@ -22,8 +22,8 @@ module Admin
end
def redownload
@account.avatar = @account.avatar_remote_url
@account.header = @account.header_remote_url
@account.reset_avatar!
@account.reset_header!
@account.save!
redirect_to admin_account_path(@account.id)

View File

@@ -8,13 +8,21 @@ module Admin
site_title
site_description
site_extended_description
site_terms
open_registrations
closed_registrations_message
open_deletion
timeline_preview
).freeze
BOOLEAN_SETTINGS = %w(
open_registrations
open_deletion
timeline_preview
).freeze
BOOLEAN_SETTINGS = %w(open_registrations).freeze
def edit
@settings = Setting.all_as_records
@admin_settings = Form::AdminSettings.new
end
def update
@@ -23,19 +31,19 @@ module Admin
setting.update(value: value_for_update(key, value))
end
flash[:notice] = 'Success!'
flash[:notice] = I18n.t('generic.changes_saved_msg')
redirect_to edit_admin_settings_path
end
private
def settings_params
params.permit(ADMIN_SETTINGS)
params.require(:form_admin_settings).permit(ADMIN_SETTINGS)
end
def value_for_update(key, value)
if BOOLEAN_SETTINGS.include?(key)
value == 'true'
value == '1'
else
value
end

View File

@@ -5,8 +5,7 @@ class Api::OEmbedController < Api::BaseController
def show
@stream_entry = find_stream_entry.stream_entry
@width = maxwidth_or_default
@height = maxheight_or_default
render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
end
private

View File

@@ -6,13 +6,13 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
def show
@account = current_account
render 'api/v1/accounts/show'
render json: @account, serializer: REST::CredentialAccountSerializer
end
def update
current_account.update!(account_params)
@account = current_account
render 'api/v1/accounts/show'
render json: @account, serializer: REST::CredentialAccountSerializer
end
private

View File

@@ -9,7 +9,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
def index
@accounts = load_accounts
render 'api/v1/accounts/index'
render json: @accounts, each_serializer: REST::AccountSerializer
end
private

View File

@@ -9,7 +9,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
def index
@accounts = load_accounts
render 'api/v1/accounts/index'
render json: @accounts, each_serializer: REST::AccountSerializer
end
private

View File

@@ -8,16 +8,15 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
def index
@accounts = Account.where(id: account_ids).select('id')
@following = Account.following_map(account_ids, current_user.account_id)
@followed_by = Account.followed_by_map(account_ids, current_user.account_id)
@blocking = Account.blocking_map(account_ids, current_user.account_id)
@muting = Account.muting_map(account_ids, current_user.account_id)
@requested = Account.requested_map(account_ids, current_user.account_id)
@domain_blocking = Account.domain_blocking_map(account_ids, current_user.account_id)
render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships
end
private
def relationships
AccountRelationshipsPresenter.new(@accounts, current_user.account_id)
end
def account_ids
@_account_ids ||= Array(params[:id]).map(&:to_i)
end

View File

@@ -8,8 +8,7 @@ class Api::V1::Accounts::SearchController < Api::BaseController
def show
@accounts = account_search
render 'api/v1/accounts/index'
render json: @accounts, each_serializer: REST::AccountSerializer
end
private

View File

@@ -9,6 +9,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
@@ -18,9 +19,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
def load_statuses
cached_account_statuses.tap do |statuses|
set_maps(statuses)
end
cached_account_statuses
end
def cached_account_statuses

View File

@@ -8,49 +8,38 @@ class Api::V1::AccountsController < Api::BaseController
respond_to :json
def show; end
def show
render json: @account, serializer: REST::AccountSerializer
end
def follow
FollowService.new.call(current_user.account, @account.acct)
set_relationship
render :relationship
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def block
BlockService.new.call(current_user.account, @account)
@following = { @account.id => false }
@followed_by = { @account.id => false }
@blocking = { @account.id => true }
@requested = { @account.id => false }
@muting = { @account.id => current_account.muting?(@account.id) }
@domain_blocking = { @account.id => current_account.domain_blocking?(@account.domain) }
render :relationship
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def mute
MuteService.new.call(current_user.account, @account)
set_relationship
render :relationship
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def unfollow
UnfollowService.new.call(current_user.account, @account)
set_relationship
render :relationship
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def unblock
UnblockService.new.call(current_user.account, @account)
set_relationship
render :relationship
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def unmute
UnmuteService.new.call(current_user.account, @account)
set_relationship
render :relationship
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
private
@@ -59,12 +48,7 @@ class Api::V1::AccountsController < Api::BaseController
@account = Account.find(params[:id])
end
def set_relationship
@following = Account.following_map([@account.id], current_user.account_id)
@followed_by = Account.followed_by_map([@account.id], current_user.account_id)
@blocking = Account.blocking_map([@account.id], current_user.account_id)
@muting = Account.muting_map([@account.id], current_user.account_id)
@requested = Account.requested_map([@account.id], current_user.account_id)
@domain_blocking = Account.domain_blocking_map([@account.id], current_user.account_id)
def relationships
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
end
end

View File

@@ -5,6 +5,7 @@ class Api::V1::AppsController < Api::BaseController
def create
@app = Doorkeeper::Application.create!(application_options)
render json: @app, serializer: REST::ApplicationSerializer
end
private

View File

@@ -9,6 +9,7 @@ class Api::V1::BlocksController < Api::BaseController
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private

View File

@@ -9,14 +9,13 @@ class Api::V1::FavouritesController < Api::BaseController
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def load_statuses
cached_favourites.tap do |statuses|
set_maps(statuses)
end
cached_favourites
end
def cached_favourites

View File

@@ -7,6 +7,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
def authorize

View File

@@ -10,7 +10,7 @@ class Api::V1::FollowsController < Api::BaseController
raise ActiveRecord::RecordNotFound if follow_params[:uri].blank?
@account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
render :show
render json: @account, serializer: REST::AccountSerializer
end
private

View File

@@ -3,5 +3,7 @@
class Api::V1::InstancesController < Api::BaseController
respond_to :json
def show; end
def show
render json: {}, serializer: REST::InstanceSerializer
end
end

View File

@@ -11,6 +11,7 @@ class Api::V1::MediaController < Api::BaseController
def create
@media = current_account.media_attachments.create!(file: media_params[:file])
render json: @media, serializer: REST::MediaAttachmentSerializer
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: file_type_error, status: 422
rescue Paperclip::Error

View File

@@ -9,6 +9,7 @@ class Api::V1::MutesController < Api::BaseController
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private

View File

@@ -11,11 +11,12 @@ class Api::V1::NotificationsController < Api::BaseController
def index
@notifications = load_notifications
set_maps_for_notification_target_statuses
render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
end
def show
@notification = current_account.notifications.find(params[:id])
render json: @notification, serializer: REST::NotificationSerializer
end
def clear
@@ -46,10 +47,6 @@ class Api::V1::NotificationsController < Api::BaseController
current_account.notifications.browserable(exclude_types)
end
def set_maps_for_notification_target_statuses
set_maps target_statuses_from_notifications
end
def target_statuses_from_notifications
@notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status)
end

View File

@@ -9,6 +9,7 @@ class Api::V1::ReportsController < Api::BaseController
def index
@reports = current_account.reports
render json: @reports, each_serializer: REST::ReportSerializer
end
def create
@@ -17,7 +18,10 @@ class Api::V1::ReportsController < Api::BaseController
status_ids: reported_status_ids,
comment: report_params[:comment]
)
render :show
User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
render json: @report, serializer: REST::ReportSerializer
end
private

View File

@@ -3,10 +3,14 @@
class Api::V1::SearchController < Api::BaseController
RESULTS_LIMIT = 5
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
respond_to :json
def index
@search = OpenStruct.new(search_results)
@search = Search.new(search_results)
render json: @search, serializer: REST::SearchSerializer
end
private

View File

@@ -11,7 +11,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
def index
@accounts = load_accounts
render 'api/v1/statuses/accounts'
render json: @accounts, each_serializer: REST::AccountSerializer
end
private

View File

@@ -10,7 +10,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
def create
@status = favourited_status
render 'api/v1/statuses/show'
render json: @status, serializer: REST::StatusSerializer
end
def destroy
@@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
UnfavouriteWorker.perform_async(current_user.account_id, @status.id)
render 'api/v1/statuses/show'
render json: @status, serializer: REST::StatusSerializer
end
private

View File

@@ -14,14 +14,14 @@ class Api::V1::Statuses::MutesController < Api::BaseController
current_account.mute_conversation!(@conversation)
@mutes_map = { @conversation.id => true }
render 'api/v1/statuses/show'
render json: @status, serializer: REST::StatusSerializer
end
def destroy
current_account.unmute_conversation!(@conversation)
@mutes_map = { @conversation.id => false }
render 'api/v1/statuses/show'
render json: @status, serializer: REST::StatusSerializer
end
private

View File

@@ -11,7 +11,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
def index
@accounts = load_accounts
render 'api/v1/statuses/accounts'
render json: @accounts, each_serializer: REST::AccountSerializer
end
private

View File

@@ -10,7 +10,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
def create
@status = ReblogService.new.call(current_user.account, status_for_reblog)
render 'api/v1/statuses/show'
render json: @status, serializer: REST::StatusSerializer
end
def destroy
@@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
authorize status_for_destroy, :unreblog?
RemovalWorker.perform_async(status_for_destroy.id)
render 'api/v1/statuses/show'
render json: @status, serializer: REST::StatusSerializer
end
private

View File

@@ -13,6 +13,7 @@ class Api::V1::StatusesController < Api::BaseController
def show
cached = Rails.cache.read(@status.cache_key)
@status = cached unless cached.nil?
render json: @status, serializer: REST::StatusSerializer
end
def context
@@ -21,15 +22,20 @@ class Api::V1::StatusesController < Api::BaseController
loaded_ancestors = cache_collection(ancestors_results, Status)
loaded_descendants = cache_collection(descendants_results, Status)
@context = OpenStruct.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context[:ancestors] + @context[:descendants]
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
set_maps(statuses)
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end
def card
@card = PreviewCard.find_by(status: @status)
render_empty if @card.nil?
if @card.nil?
render_empty
else
render json: @card, serializer: REST::PreviewCardSerializer
end
end
def create
@@ -43,7 +49,7 @@ class Api::V1::StatusesController < Api::BaseController
application: doorkeeper_token.application,
idempotency: request.headers['Idempotency-Key'])
render :show
render json: @status, serializer: REST::StatusSerializer
end
def destroy

View File

@@ -9,15 +9,13 @@ class Api::V1::Timelines::HomeController < Api::BaseController
def show
@statuses = load_statuses
render 'api/v1/timelines/show'
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def load_statuses
cached_home_statuses.tap do |statuses|
set_maps(statuses)
end
cached_home_statuses
end
def cached_home_statuses

View File

@@ -7,15 +7,13 @@ class Api::V1::Timelines::PublicController < Api::BaseController
def show
@statuses = load_statuses
render 'api/v1/timelines/show'
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def load_statuses
cached_public_statuses.tap do |statuses|
set_maps(statuses)
end
cached_public_statuses
end
def cached_public_statuses

View File

@@ -8,7 +8,7 @@ class Api::V1::Timelines::TagController < Api::BaseController
def show
@statuses = load_statuses
render 'api/v1/timelines/show'
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
@@ -18,9 +18,7 @@ class Api::V1::Timelines::TagController < Api::BaseController
end
def load_statuses
cached_tagged_statuses.tap do |statuses|
set_maps(statuses)
end
cached_tagged_statuses
end
def cached_tagged_statuses

View File

@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
include UserTrackingConcern
helper_method :current_account
helper_method :current_session
helper_method :single_user_mode?
rescue_from ActionController::RoutingError, with: :not_found
@@ -68,6 +69,10 @@ class ApplicationController < ActionController::Base
@current_account ||= current_user.try(:account)
end
def current_session
@current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
end
def cache_collection(raw, klass)
return raw unless klass.respond_to?(:with_includes)

View File

@@ -5,6 +5,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
def destroy
not_found
@@ -41,4 +42,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth'
end
def set_sessions
@sessions = current_user.session_activations
end
end

View File

@@ -15,7 +15,7 @@ class AuthorizeFollowsController < ApplicationController
if @account.nil?
render :error
else
redirect_to web_url("accounts/#{@account.id}")
render :success
end
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
render :error

View File

@@ -2,14 +2,10 @@
class HomeController < ApplicationController
before_action :authenticate_user!
before_action :set_initial_state_json
def index
@body_classes = 'app-body'
@token = find_or_create_access_token.token
@web_settings = Web::Setting.find_by(user: current_user)&.data || {}
@admin = Account.find_local(Setting.site_contact_username)
@streaming_api_base_url = Rails.configuration.x.streaming_api_base_url
@frontend = (params[:frontend] and Rails.configuration.x.available_frontends.include? params[:frontend] + '.js') ? params[:frontend] : 'mastodon'
@body_classes = 'app-body'
end
private
@@ -18,13 +14,17 @@ class HomeController < ApplicationController
redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in?
end
def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(
Doorkeeper::Application.where(superapp: true).first,
current_user.id,
Doorkeeper::OAuth::Scopes.from_string('read write follow'),
Doorkeeper.configuration.access_token_expires_in,
Doorkeeper.configuration.refresh_token_enabled?
)
def set_initial_state_json
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
def initial_state_params
{
settings: Web::Setting.find_by(user: current_user)&.data || {},
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username),
}
end
end

View File

@@ -34,9 +34,11 @@ class Settings::PreferencesController < ApplicationController
def user_settings_params
params.require(:user).permit(
:setting_default_privacy,
:setting_default_sensitive,
:setting_boost_modal,
:setting_delete_modal,
:setting_auto_play_gif,
:setting_system_font_ui,
notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following)
)

View File

@@ -7,7 +7,9 @@ module Settings
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
def show; end
def show
@confirmation = Form::TwoFactorConfirmation.new
end
def create
current_user.otp_secret = User.generate_otp_secret(32)
@@ -16,13 +18,23 @@ module Settings
end
def destroy
current_user.otp_required_for_login = false
current_user.save!
redirect_to settings_two_factor_authentication_path
if current_user.validate_and_consume_otp!(confirmation_params[:code])
current_user.otp_required_for_login = false
current_user.save!
redirect_to settings_two_factor_authentication_path
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
@confirmation = Form::TwoFactorConfirmation.new
render :show
end
end
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:code)
end
def verify_otp_required
redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
end

View File

@@ -6,15 +6,21 @@ module Admin::FilterHelper
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS
def filter_link_to(text, more_params)
new_url = filtered_url_for(more_params)
link_to text, new_url, class: filter_link_class(new_url)
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
new_url = filtered_url_for(link_to_params)
new_class = filtered_url_for(link_class_params)
link_to text, new_url, class: filter_link_class(new_class)
end
def table_link_to(icon, text, path, options = {})
link_to safe_join([fa_icon(icon), text]), path, options.merge(class: 'table-action-link')
end
def selected?(more_params)
new_url = filtered_url_for(more_params)
filter_link_class(new_url) == 'selected' ? true : false
end
private
def filter_params(more_params)

View File

@@ -31,7 +31,11 @@ module ApplicationHelper
Rails.env.production? ? site_title : "#{site_title} (Dev)"
end
def fa_icon(icon)
content_tag(:i, nil, class: 'fa ' + icon.split(' ').map { |cl| "fa-#{cl}" }.join(' '))
def fa_icon(icon, attributes = {})
class_names = attributes[:class]&.split(' ') || []
class_names << 'fa'
class_names += icon.split(' ').map { |cl| "fa-#{cl}" }
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
end
end

View File

@@ -19,6 +19,7 @@ module SettingsHelper
io: 'Ido',
it: 'Italiano',
ja: '日本語',
ko: '한국어',
nl: 'Nederlands',
no: 'Norsk',
oc: 'Occitan',
@@ -41,4 +42,16 @@ module SettingsHelper
def hash_to_object(hash)
HashObject.new(hash)
end
def session_device_icon(session)
device = session.detection.device
if device.mobile?
'mobile'
elsif device.tablet?
'tablet'
else
'desktop'
end
end
end

Binary file not shown.

View File

@@ -0,0 +1,20 @@
export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
export function changeLocalSetting(key, value) {
return dispatch => {
dispatch({
type: LOCAL_SETTING_CHANGE,
key,
value,
});
dispatch(saveLocalSettings());
};
};
export function saveLocalSettings() {
return (_, getState) => {
const localSettings = getState().get('local_settings').toJS();
localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
};
};

View File

@@ -0,0 +1,112 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import IconButton from '../../../mastodon/components/icon_button';
import Avatar from '../../../mastodon/components/avatar';
// Our imports //
import { processBio } from '../../util/bio_metadata';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
});
@injectIntl
export default class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map,
me: PropTypes.number.isRequired,
onFollow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { account, me, intl } = this.props;
if (!account) {
return null;
}
let displayName = account.get('display_name');
let info = '';
let actionBtn = '';
let lockedIcon = '';
if (displayName.length === 0) {
displayName = account.get('username');
}
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
}
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
</div>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
</div>
);
}
}
if (account.get('locked')) {
lockedIcon = <i className='fa fa-lock' />;
}
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const { text, metadata } = processBio(account.get('note'));
return (
<div className='account__header__wrapper'>
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div>
<a href={account.get('url')} target='_blank' rel='noopener'>
<span className='account__header__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={90} /></span>
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
</a>
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
{info}
{actionBtn}
</div>
</div>
{metadata.length && (
<table className='account__metadata'>
{(() => {
let data = [];
for (let i = 0; i < metadata.length; i++) {
data.push(
<tr key={i}>
<th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
<td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
</tr>
);
}
return data;
})()}
</table>
) || null}
</div>
);
}
}

View File

@@ -0,0 +1,112 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Toggle from 'react-toggle';
import { injectIntl, defineMessages } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../mastodon/components/icon_button';
const messages = defineMessages({
local_only_short: { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
local_only_long: { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
advanced_options_icon_title: { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
});
const iconStyle = {
height: null,
lineHeight: '27px',
};
@injectIntl
export default class ComposeAdvancedOptions extends React.PureComponent {
static propTypes = {
values: ImmutablePropTypes.contains({
do_not_federate: PropTypes.bool.isRequired,
}).isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
onToggleDropdown = () => {
this.setState({ open: !this.state.open });
};
onGlobalClick = (e) => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
this.setState({ open: false });
}
}
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
state = {
open: false,
};
handleClick = (e) => {
const option = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onChange(option);
}
toggleHandler(option) {
return () => this.props.onChange(option);
}
setRef = (c) => {
this.node = c;
}
render () {
const { open } = this.state;
const { intl, values } = this.props;
const options = [
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, key: 'do_not_federate' },
];
const anyEnabled = values.some((enabled) => enabled);
const optionElems = options.map((option) => {
const active = values.get(option.key);
return (
<div role='button' className='advanced-options-dropdown__option' key={option.key} >
<div className='advanced-options-dropdown__option__toggle'>
<Toggle checked={active} onChange={this.toggleHandler(option.key)} />
</div>
<div className='advanced-options-dropdown__option__content'>
<strong>{intl.formatMessage(option.shortText)}</strong>
{intl.formatMessage(option.longText)}
</div>
</div>
);
});
return (<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${anyEnabled ? 'active' : ''} `}>
<div className='advanced-options-dropdown__value'>
<IconButton
className='advanced-options-dropdown__value'
title={intl.formatMessage(messages.advanced_options_icon_title)}
icon='ellipsis-h' active={open || anyEnabled}
size={18}
style={iconStyle}
onClick={this.onToggleDropdown}
/>
</div>
<div className='advanced-options-dropdown__dropdown'>
{optionElems}
</div>
</div>);
}
}

View File

@@ -0,0 +1,93 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import AccountContainer from '../../../mastodon/containers/account_container';
import Permalink from '../../../mastodon/components/permalink';
import emojify from '../../../mastodon/emoji';
// Our imports //
import StatusContainer from '../../containers/status';
export default class Notification extends ImmutablePureComponent {
static propTypes = {
notification: ImmutablePropTypes.map.isRequired,
settings: ImmutablePropTypes.map.isRequired,
};
renderFollow (notification) {
const account = notification.get('account');
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
return (
<div className='notification notification-follow'>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<i className='fa fa-fw fa-user-plus' />
</div>
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
</div>
<AccountContainer id={account.get('id')} withNote={false} />
</div>
);
}
renderMention (notification) {
return (
<StatusContainer
id={notification.get('status')}
withDismiss
/>
);
}
renderFavourite (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='favourite'
muted
withDismiss
/>
);
}
renderReblog (notification) {
return (
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
prepend='reblog'
muted
withDismiss
/>
);
}
render () {
const { notification } = this.props;
switch(notification.get('type')) {
case 'follow':
return this.renderFollow(notification);
case 'mention':
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification);
case 'reblog':
return this.renderReblog(notification);
}
return null;
}
}

View File

@@ -0,0 +1,221 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
// Our imports //
import SettingsItem from './item';
const messages = defineMessages({
layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' },
layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
});
@injectIntl
export default class Settings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
toggleSetting: PropTypes.func.isRequired,
changeSetting: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
currentIndex: 0,
};
General = () => {
const { intl } = this.props;
return (
<div>
<h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
<SettingsItem
settings={this.props.settings}
item={['layout']}
id='mastodon-settings--layout'
options={[
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
]}
onChange={this.props.changeSetting}
>
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['stretch']}
id='mastodon-settings--stretch'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
</SettingsItem>
</div>
);
}
CollapsedStatuses = () => {
return (
<div>
<h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'enabled']}
id='mastodon-settings--collapsed-enabled'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
</SettingsItem>
<section>
<h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'all']}
id='mastodon-settings--collapsed-auto-all'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'notifications']}
id='mastodon-settings--collapsed-auto-notifications'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'lengthy']}
id='mastodon-settings--collapsed-auto-lengthy'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'replies']}
id='mastodon-settings--collapsed-auto-replies'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'auto', 'media']}
id='mastodon-settings--collapsed-auto-media'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
dependsOnNot={[['collapsed', 'auto', 'all']]}
>
<FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
</SettingsItem>
</section>
<section>
<h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'backgrounds', 'user_backgrounds']}
id='mastodon-settings--collapsed-user-backgrouns'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['collapsed', 'backgrounds', 'preview_images']}
id='mastodon-settings--collapsed-preview-images'
onChange={this.props.toggleSetting}
dependsOn={[['collapsed', 'enabled']]}
>
<FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
</SettingsItem>
</section>
</div>
);
}
Media = () => {
return (
<div>
<h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
<SettingsItem
settings={this.props.settings}
item={['media', 'letterbox']}
id='mastodon-settings--media-letterbox'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
</SettingsItem>
<SettingsItem
settings={this.props.settings}
item={['media', 'fullwidth']}
id='mastodon-settings--media-fullwidth'
onChange={this.props.toggleSetting}
>
<FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
</SettingsItem>
</div>
);
}
navigateTo = (e) =>
this.setState({ currentIndex: +e.currentTarget.getAttribute('data-mastodon-navigation_index') });
render () {
const { General, CollapsedStatuses, Media, navigateTo } = this;
const { onClose } = this.props;
const { currentIndex } = this.state;
return (
<div className='modal-root__modal settings-modal'>
<nav className='settings-modal__navigation'>
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='0' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 0 ? ' active' : ''}`}>
<FormattedMessage id='settings.general' defaultMessage='General' />
</a>
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='1' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 1 ? ' active' : ''}`}>
<FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' />
</a>
<a onClick={navigateTo} role='button' data-mastodon-navigation_index='2' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 2 ? ' active' : ''}`}>
<FormattedMessage id='settings.media' defaultMessage='Media' />
</a>
<a href='/settings/preferences' className='settings-modal__navigation-item'>
<i className='fa fa-fw fa-cog' /> <FormattedMessage id='settings.preferences' defaultMessage='User preferences' />
</a>
<a onClick={onClose} role='button' tabIndex='0' className='settings-modal__navigation-close'>
<FormattedMessage id='settings.close' defaultMessage='Close' />
</a>
</nav>
<div className='settings-modal__content'>
{
[
<General />,
<CollapsedStatuses />,
<Media />,
][currentIndex] || <General />
}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,79 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
export default class SettingsItem extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
item: PropTypes.array.isRequired,
id: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired,
message: PropTypes.object.isRequired,
})),
dependsOn: PropTypes.array,
dependsOnNot: PropTypes.array,
children: PropTypes.element.isRequired,
onChange: PropTypes.func.isRequired,
};
handleChange = (e) => {
const { item, onChange } = this.props;
onChange(item, e);
}
render () {
const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
let enabled = true;
if (dependsOn) {
for (let i = 0; i < dependsOn.length; i++) {
enabled = enabled && settings.getIn(dependsOn[i]);
}
}
if (dependsOnNot) {
for (let i = 0; i < dependsOnNot.length; i++) {
enabled = enabled && !settings.getIn(dependsOnNot[i]);
}
}
if (options && options.length > 0) {
const currentValue = settings.getIn(item);
const optionElems = options && options.length > 0 && options.map((opt) => (
<option key={opt.value} selected={currentValue === opt.value} value={opt.value} >
{opt.message}
</option>
));
return (
<label htmlFor={id}>
<p>{children}</p>
<p>
<select
id={id}
disabled={!enabled}
onBlur={this.handleChange}
>
{optionElems}
</select>
</p>
</label>
);
} else {
return (
<label htmlFor={id}>
<input
id={id}
type='checkbox'
checked={settings.getIn(item)}
onChange={this.handleChange}
disabled={!enabled}
/>
{children}
</label>
);
}
}
}

View File

@@ -0,0 +1,159 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
import IconButton from '../../../mastodon/components/icon_button';
import DropdownMenu from '../../../mastodon/components/dropdown_menu';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
});
@injectIntl
export default class StatusActionBar extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onMention: PropTypes.func,
onMute: PropTypes.func,
onBlock: PropTypes.func,
onReport: PropTypes.func,
onMuteConversation: PropTypes.func,
me: PropTypes.number.isRequired,
withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'me',
'withDismiss',
]
handleReplyClick = () => {
this.props.onReply(this.props.status, this.context.router.history);
}
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
handleReblogClick = (e) => {
this.props.onReblog(this.props.status, e);
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
}
handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
handleMuteClick = () => {
this.props.onMute(this.props.status.get('account'));
}
handleBlockClick = () => {
this.props.onBlock(this.props.status.get('account'));
}
handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
handleReport = () => {
this.props.onReport(this.props.status);
}
handleConversationMuteClick = () => {
this.props.onMuteConversation(this.props.status);
}
render () {
const { status, me, intl, withDismiss } = this.props;
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
const mutingConversation = status.get('muted');
let menu = [];
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
menu.push(null);
if (withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
}
if (status.getIn(['account', 'id']) === me) {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
}
/*
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
*/
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
<div className='status__action-bar-dropdown'>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
</div>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
);
}
}

View File

@@ -0,0 +1,239 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import { isRtl } from '../../../mastodon/rtl';
import Permalink from '../../../mastodon/components/permalink';
export default class StatusContent extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.oneOf([true, false, null]),
setExpansion: PropTypes.func,
onHeightUpdate: PropTypes.func,
media: PropTypes.element,
mediaIcon: PropTypes.string,
parseClick: PropTypes.func,
};
state = {
hidden: true,
};
componentDidMount () {
const node = this.node;
const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.addEventListener('click', this.onLinkClick.bind(this), false);
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
link.setAttribute('title', link.href);
}
}
}
componentDidUpdate () {
if (this.props.onHeightUpdate) {
this.props.onHeightUpdate();
}
}
onLinkClick = (e) => {
if (this.props.expanded === false) {
if (this.props.parseClick) this.props.parseClick(e);
}
}
onMentionClick = (mention, e) => {
if (this.props.parseClick) {
this.props.parseClick(e, `/accounts/${mention.get('id')}`);
}
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (this.props.parseClick) {
this.props.parseClick(e, `/timelines/tag/${hashtag}`);
}
}
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
handleMouseUp = (e) => {
const { parseClick } = this.props;
if (!this.startXY) {
return;
}
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
parseClick(e);
}
this.startXY = null;
}
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.setExpansion) {
this.props.setExpansion(this.props.expanded ? null : true);
} else {
this.setState({ hidden: !this.state.hidden });
}
}
setRef = (c) => {
this.node = c;
}
render () {
const { status, media, mediaIcon } = this.props;
const hidden = (
this.props.setExpansion ?
!this.props.expanded :
this.state.hidden
);
const content = { __html: emojify(status.get('content')) };
const spoilerContent = {
__html: emojify(escapeTextContentForBrowser(
status.get('spoiler_text', '')
)),
};
const directionStyle = { direction: 'ltr' };
if (isRtl(status.get('search_index'))) {
directionStyle.direction = 'rtl';
}
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Permalink
to={`/accounts/${item.get('id')}`}
href={item.get('url')}
key={item.get('id')}
className='mention'
>
@<span>{item.get('username')}</span>
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? [
<FormattedMessage
id='status.show_more'
defaultMessage='Show more'
key='0'
/>,
mediaIcon ? (
<i
className={
`fa fa-fw fa-${mediaIcon} status__content__spoiler-icon`
}
aria-hidden='true'
key='1'
/>
) : null,
] : [
<FormattedMessage
id='status.show_less'
defaultMessage='Show less'
key='0'
/>,
];
if (hidden) {
mentionsPlaceholder = <div>{mentionLinks}</div>;
}
return (
<div className='status__content status__content--with-action' ref={this.setRef}>
<p
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
>
<span dangerouslySetInnerHTML={spoilerContent} />
{' '}
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
{toggleText}
</button>
</p>
{mentionsPlaceholder}
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
<div
style={directionStyle}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{media}
</div>
</div>
);
} else if (this.props.parseClick) {
return (
<div
ref={this.setRef}
className='status__content status__content--with-action'
style={directionStyle}
>
<div
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{media}
</div>
);
} else {
return (
<div
ref={this.setRef}
className='status__content'
style={directionStyle}
>
<div dangerouslySetInnerHTML={content} />
{media}
</div>
);
}
}
}

View File

@@ -0,0 +1,79 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../../mastodon/components/icon_button';
// Our imports //
import StatusGalleryItem from './item';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
});
@injectIntl
export default class StatusGallery extends React.PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
state = {
visible: !this.props.sensitive,
};
handleOpen = () => {
this.setState({ visible: !this.state.visible });
}
handleClick = (index) => {
this.props.onOpenMedia(this.props.media, index);
}
render () {
const { media, intl, sensitive, letterbox, fullwidth } = this.props;
let children;
if (!this.state.visible) {
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
children = (
<div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <StatusGalleryItem key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} letterbox={letterbox} />);
}
return (
<div className={`media-gallery ${fullwidth ? 'full-width' : ''}`} style={{ height: `${this.props.height}px` }}>
<div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div>
{children}
</div>
);
}
}

View File

@@ -0,0 +1,132 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
// Mastodon imports //
import { isIOS } from '../../../../mastodon/is_mobile';
export default class StatusGalleryItem extends React.PureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
handleClick = (e) => {
const { index, onClick } = this.props;
if (e.button === 0) {
e.preventDefault();
onClick(index);
}
e.stopPropagation();
}
render () {
const { attachment, index, size, letterbox } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
let thumbnail = '';
if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
thumbnail = (
<a
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
>
<img className={letterbox ? 'letterbox' : ''} src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;
thumbnail = (
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
<video
className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
role='application'
src={attachment.get('url')}
onClick={this.handleClick}
autoPlay={autoPlay}
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
</div>
);
}
}

View File

@@ -0,0 +1,247 @@
/*
`<StatusHeader>`
================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
// Mastodon imports //
import Avatar from '../../../mastodon/components/avatar';
import AvatarOverlay from '../../../mastodon/components/avatar_overlay';
import DisplayName from '../../../mastodon/components/display_name';
import IconButton from '../../../mastodon/components/icon_button';
/* * * * */
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we need
from inside props. In our case, these are the `collapse` and
`uncollapse` messages used with our collapse/uncollapse buttons.
*/
const messages = defineMessages({
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
/* * * * */
/*
The `<StatusHeader>` component:
-------------------------------
The `<StatusHeader>` component wraps together the header information
(avatar, display name) and upper buttons and icons (collapsing, media
icons) into a single `<header>` element.
### Props
- __`account`, `friend` (`ImmutablePropTypes.map`) :__
These give the accounts associated with the status. `account` is
the author of the post; `friend` will have their avatar appear
in the overlay if provided.
- __`mediaIcon` (`PropTypes.string`) :__
If a mediaIcon should be placed in the header, this string
specifies it.
- __`collapsible`, `collapsed` (`PropTypes.bool`) :__
These props tell whether a post can be, and is, collapsed.
- __`parseClick` (`PropTypes.func`) :__
This function will be called when the user clicks inside the header
information.
- __`setExpansion` (`PropTypes.func`) :__
This function is used to set the expansion state of the post.
- __`intl` (`PropTypes.object`) :__
This is our internationalization object, provided by
`injectIntl()`.
*/
@injectIntl
export default class StatusHeader extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map,
mediaIcon: PropTypes.string,
collapsible: PropTypes.bool,
collapsed: PropTypes.bool,
parseClick: PropTypes.func.isRequired,
setExpansion: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
visibility: PropTypes.string,
};
/*
### Implementation
#### `handleCollapsedClick()`.
`handleCollapsedClick()` is just a simple callback for our collapsing
button. It calls `setExpansion` to set the collapsed state of the
status.
*/
handleCollapsedClick = (e) => {
const { collapsed, setExpansion } = this.props;
if (e.button === 0) {
setExpansion(collapsed ? null : false);
e.preventDefault();
}
}
/*
#### `handleAccountClick()`.
`handleAccountClick()` handles any clicks on the header info. It calls
`parseClick()` with our `account` as the anticipatory `destination`.
*/
handleAccountClick = (e) => {
const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`);
}
/*
#### `render()`.
`render()` actually puts our element on the screen. `<StatusHeader>`
has a very straightforward rendering process.
*/
render () {
const {
account,
friend,
mediaIcon,
collapsible,
collapsed,
intl,
visibility,
} = this.props;
const visibilityClass = {
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
}[visibility];
return (
<header className='status__info'>
{
/*
We have to include the status icons before the header content because
it is rendered as a float.
*/
}
<div className='status__info__icons'>
{mediaIcon ? (
<i
className={`fa fa-fw fa-${mediaIcon}`}
aria-hidden='true'
/>
) : null}
{(
<i
className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`}
title={intl.formatMessage(messages[visibility])}
aria-hidden='true'
/>
)}
{collapsible ? (
<IconButton
className='status__collapse-button'
animate flip
active={collapsed}
title={
collapsed ?
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
}
icon='angle-double-up'
onClick={this.handleCollapsedClick}
/>
) : null}
</div>
{
/*
This begins our header content. It is all wrapped inside of a link
which gets handled by `handleAccountClick`. We use an `<AvatarOverlay>`
if we have a `friend` and a normal `<Avatar>` if we don't.
*/
}
<a
href={account.get('url')}
className='status__display-name'
onClick={this.handleAccountClick}
>
<div className='status__avatar'>{
friend ? (
<AvatarOverlay
staticSrc={account.get('avatar_static')}
overlaySrc={friend.get('avatar_static')}
/>
) : (
<Avatar
src={account.get('avatar')}
staticSrc={account.get('avatar_static')}
size={48}
/>
)
}</div>
<DisplayName account={account} />
</a>
</header>
);
}
}

View File

@@ -0,0 +1,719 @@
/*
`<Status>`
==========
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. *Heavily* rewritten (and documented!) by
@kibi@glitch.social as a part of glitch-soc/mastodon. The following
features have been added:
- Better separating the "guts" of statuses from their wrapper(s)
- Collapsing statuses
- Moving images inside of CWs
A number of aspects of this original file have been split off into
their own components for better maintainance; for these, see:
- <StatusHeader>
- <StatusPrepend>
…And, of course, the other <Status>-related components as well.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
// Our imports //
import StatusPrepend from './prepend';
import StatusHeader from './header';
import StatusContent from './content';
import StatusActionBar from './action_bar';
import StatusGallery from './gallery';
import StatusVideoPlayer from './video_player';
/* * * * */
/*
The `<Status>` component:
-------------------------
The `<Status>` component is a container for statuses. It consists of a
few parts:
- The `<StatusPrepend>`, which contains tangential information about
the status, such as who reblogged it.
- The `<StatusHeader>`, which contains the avatar and username of the
status author, as well as a media icon and the "collapse" toggle.
- The `<StatusContent>`, which contains the content of the status.
- The `<StatusActionBar>`, which provides actions to be performed
on statuses, like reblogging or sending a reply.
### Context
- __`router` (`PropTypes.object`) :__
We need to get our router from the surrounding React context.
### Props
- __`id` (`PropTypes.number`) :__
The id of the status.
- __`status` (`ImmutablePropTypes.map`) :__
The status object, straight from the store.
- __`account` (`ImmutablePropTypes.map`) :__
Don't be confused by this one! This is **not** the account which
posted the status, but the associated account with any further
action (eg, a reblog or a favourite).
- __`settings` (`ImmutablePropTypes.map`) :__
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`) :__
These are all functions passed through from the
`<StatusContainer>`. We don't deal with them directly here.
- __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__
These tell whether or not the user has modals activated for
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
notifications column. This should be `true` for notifications, and
`false` otherwise.
- __`collapse` (`PropTypes.bool`) :__
This prop signals a directive from a higher power to (un)collapse
a status. Most of the time it should be `undefined`, in which case
we do nothing.
- __`prepend` (`PropTypes.string`) :__
The type of prepend: `'reblogged_by'`, `'reblog'`, or
`'favourite'`.
- __`withDismiss` (`PropTypes.bool`) :__
Whether or not the status can be dismissed. Used for notifications.
- __`intersectionObserverWrapper` (`PropTypes.object`) :__
This holds our intersection observer. In Mastodon parlance,
an "intersection" is just when the status is viewable onscreen.
### State
- __`isExpanded` :__
Should be either `true`, `false`, or `null`. The meanings of
these values are as follows:
- __`true` :__ The status contains a CW and the CW is expanded.
- __`false` :__ The status is collapsed.
- __`null` :__ The status is not collapsed or expanded.
- __`isIntersecting` :__
This boolean tells us whether or not the status is currently
onscreen.
- __`isHidden` :__
This boolean tells us if the status has been unrendered to save
CPUs.
*/
export default class Status extends ImmutablePureComponent {
static contextTypes = {
router : PropTypes.object,
};
static propTypes = {
id : PropTypes.number,
status : ImmutablePropTypes.map,
account : ImmutablePropTypes.map,
settings : ImmutablePropTypes.map,
me : PropTypes.number,
onFavourite : PropTypes.func,
onReblog : PropTypes.func,
onModalReblog : PropTypes.func,
onDelete : PropTypes.func,
onMention : PropTypes.func,
onMute : PropTypes.func,
onMuteConversation : PropTypes.func,
onBlock : PropTypes.func,
onReport : PropTypes.func,
onOpenMedia : PropTypes.func,
onOpenVideo : PropTypes.func,
reblogModal : PropTypes.bool,
deleteModal : PropTypes.bool,
autoPlayGif : PropTypes.bool,
muted : PropTypes.bool,
collapse : PropTypes.bool,
prepend : PropTypes.string,
withDismiss : PropTypes.bool,
intersectionObserverWrapper : PropTypes.object,
};
state = {
isExpanded : null,
isIntersecting : true,
isHidden : false,
}
/*
### Implementation
#### `updateOnProps` and `updateOnStates`.
`updateOnProps` and `updateOnStates` tell the component when to update.
We specify them explicitly because some of our props are dynamically=
generated functions, which would otherwise always trigger an update.
Of course, this means that if we add an important prop, we will need
to remember to specify it here.
*/
updateOnProps = [
'status',
'account',
'settings',
'prepend',
'me',
'boostModal',
'autoPlayGif',
'muted',
'collapse',
]
updateOnStates = [
'isExpanded',
]
/*
#### `componentWillReceiveProps()`.
If our settings have changed to disable collapsed statuses, then we
need to make sure that we uncollapse every one. We do that by watching
for changes to `settings.collapsed.enabled` in
`componentWillReceiveProps()`.
We also need to watch for changes on the `collapse` prop---if this
changes to anything other than `undefined`, then we need to collapse or
uncollapse our status accordingly.
*/
componentWillReceiveProps (nextProps) {
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
this.setExpansion(true);
} else if (
nextProps.collapse !== this.props.collapse &&
nextProps.collapse !== undefined
) this.setExpansion(nextProps.collapse ? false : null);
}
/*
#### `componentDidMount()`.
When mounting, we just check to see if our status should be collapsed,
and collapse it if so. We don't need to worry about whether collapsing
is enabled here, because `setExpansion()` already takes that into
account.
The cases where a status should be collapsed are:
- The `collapse` prop has been set to `true`
- The user has decided in local settings to collapse all statuses.
- The user has decided to collapse all notifications ('muted'
statuses).
- The user has decided to collapse long statuses and the status is
over 400px (without media, or 650px with).
- The status is a reply and the user has decided to collapse all
replies.
- The status contains media and the user has decided to collapse all
statuses with media.
We also start up our intersection observer to monitor our statuses.
`componentMounted` lets us know that everything has been set up
properly and our intersection observer is good to go.
*/
componentDidMount () {
const { node, handleIntersection } = this;
const {
status,
settings,
collapse,
muted,
id,
intersectionObserverWrapper,
} = this.props;
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
if (
collapse ||
autoCollapseSettings.get('all') || (
autoCollapseSettings.get('notifications') && muted
) || (
autoCollapseSettings.get('lengthy') &&
node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
)
) || (
autoCollapseSettings.get('replies') &&
status.get('in_reply_to_id', null) !== null
) || (
autoCollapseSettings.get('media') &&
!(status.get('spoiler_text').length) &&
status.get('media_attachments').size
)
) this.setExpansion(false);
if (!intersectionObserverWrapper) return;
else intersectionObserverWrapper.observe(
id,
node,
handleIntersection
);
this.componentMounted = true;
}
/*
#### `shouldComponentUpdate()`.
If the status is about to be both offscreen (not intersecting) and
hidden, then we only need to update it if it's not that way currently.
If the status is moving from offscreen to onscreen, then we *have* to
re-render, so that we can unhide the element if necessary.
If neither of these cases are true, we can leave it up to our
`updateOnProps` and `updateOnStates` arrays.
*/
shouldComponentUpdate (nextProps, nextState) {
switch (true) {
case !nextState.isIntersecting && nextState.isHidden:
return this.state.isIntersecting || !this.state.isHidden;
case nextState.isIntersecting && !this.state.isIntersecting:
return true;
default:
return super.shouldComponentUpdate(nextProps, nextState);
}
}
/*
#### `componentDidUpdate()`.
If our component is being rendered for any reason and an update has
triggered, this will save its height.
This is, frankly, a bit overkill, as the only instance when we
actually *need* to update the height right now should be when the
value of `isExpanded` has changed. But it makes for more readable
code and prevents bugs in the future where the height isn't set
properly after some change.
*/
componentDidUpdate () {
if (
this.state.isIntersecting || !this.state.isHidden
) this.saveHeight();
}
/*
#### `componentWillUnmount()`.
If our component is about to unmount, then we'd better unset
`this.componentMounted`.
*/
componentWillUnmount () {
this.componentMounted = false;
}
/*
#### `handleIntersection()`.
`handleIntersection()` either hides the status (if it is offscreen) or
unhides it (if it is onscreen). It's called by
`intersectionObserverWrapper.observe()`.
If our status isn't intersecting, we schedule an idle task (using the
aptly-named `scheduleIdleTask()`) to hide the status at the next
available opportunity.
tootsuite/mastodon left us with the following enlightening comment
regarding this function:
> Edge 15 doesn't support isIntersecting, but we can infer it
It then implements a polyfill (intersectionRect.height > 0) which isn't
actually sufficient. The short answer is, this behaviour isn't really
supported on Edge but we can get kinda close.
*/
handleIntersection = (entry) => {
const isIntersecting = (
typeof entry.isIntersecting === 'boolean' ?
entry.isIntersecting :
entry.intersectionRect.height > 0
);
this.setState(
(prevState) => {
if (prevState.isIntersecting && !isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting : isIntersecting,
isHidden : false,
};
}
);
}
/*
#### `hideIfNotIntersecting()`.
This function will hide the status if we're still not intersecting.
Hiding the status means that it will just render an empty div instead
of actual content, which saves RAMS and CPUs or some such.
*/
hideIfNotIntersecting = () => {
if (!this.componentMounted) return;
this.setState(
(prevState) => ({ isHidden: !prevState.isIntersecting })
);
}
/*
#### `saveHeight()`.
`saveHeight()` saves the height of our status so that when whe hide it
we preserve its dimensions. We only want to store our height, though,
if our status has content (otherwise, it would imply that it is
already hidden).
*/
saveHeight = () => {
if (this.node && this.node.children.length) {
this.height = this.node.getBoundingClientRect().height;
}
}
/*
#### `setExpansion()`.
`setExpansion()` sets the value of `isExpanded` in our state. It takes
one argument, `value`, which gives the desired value for `isExpanded`.
The default for this argument is `null`.
`setExpansion()` automatically checks for us whether toot collapsing
is enabled, so we don't have to.
We use a `switch` statement to simplify our code.
*/
setExpansion = (value) => {
switch (true) {
case value === undefined || value === null:
this.setState({ isExpanded: null });
break;
case !value && this.props.settings.getIn(['collapsed', 'enabled']):
this.setState({ isExpanded: false });
break;
case !!value:
this.setState({ isExpanded: true });
break;
}
}
/*
#### `handleRef()`.
`handleRef()` just saves a reference to our status node to `this.node`.
It also saves our height, in case the height of our node has changed.
*/
handleRef = (node) => {
this.node = node;
this.saveHeight();
}
/*
#### `parseClick()`.
`parseClick()` takes a click event and responds appropriately.
If our status is collapsed, then clicking on it should uncollapse it.
If `Shift` is held, then clicking on it should collapse it.
Otherwise, we open the url handed to us in `destination`, if
applicable.
*/
parseClick = (e, destination) => {
const { router } = this.context;
const { status } = this.props;
const { isExpanded } = this.state;
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
if (e.button === 0) {
if (isExpanded === false) this.setExpansion(null);
else if (e.shiftKey) {
this.setExpansion(false);
document.getSelection().removeAllRanges();
} else router.history.push(destination);
e.preventDefault();
}
}
/*
#### `render()`.
`render()` actually puts our element on the screen. The particulars of
this operation are further explained in the code below.
*/
render () {
const { parseClick, setExpansion, handleRef } = this;
const {
status,
account,
settings,
collapsed,
muted,
prepend,
intersectionObserverWrapper,
onOpenVideo,
onOpenMedia,
autoPlayGif,
...other
} = this.props;
const { isExpanded, isIntersecting, isHidden } = this.state;
let background = null;
let attachments = null;
let media = null;
let mediaIcon = null;
/*
If we don't have a status, then we don't render anything.
*/
if (status === null) {
return null;
}
/*
If our status is offscreen and hidden, then we render an empty <div> in
its place. We fill it with "content" but note that opacity is set to 0.
*/
if (!isIntersecting && isHidden) {
return (
<div
ref={this.handleRef}
data-id={status.get('id')}
style={{
height : `${this.height}px`,
opacity : 0,
overflow : 'hidden',
}}
>
{
status.getIn(['account', 'display_name']) ||
status.getIn(['account', 'username'])
}
{status.get('content')}
</div>
);
}
/*
If user backgrounds for collapsed statuses are enabled, then we
initialize our background accordingly. This will only be rendered if
the status is collapsed.
*/
if (
settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])
) background = status.getIn(['account', 'header']);
/*
This handles our media attachments. Note that we don't show media on
muted (notification) statuses. If the media type is unknown, then we
simply ignore it.
After we have generated our appropriate media element and stored it in
`media`, we snatch the thumbnail to use as our `background` if media
backgrounds for collapsed statuses are enabled.
*/
attachments = status.get('media_attachments');
if (attachments.size && !muted) {
if (attachments.some((item) => item.get('type') === 'unknown')) {
} else if (
attachments.getIn([0, 'type']) === 'video'
) {
media = ( // Media type is 'video'
<StatusVideoPlayer
media={attachments.get(0)}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenVideo={onOpenVideo}
/>
);
mediaIcon = 'video-camera';
} else { // Media type is 'image' or 'gifv'
media = (
<StatusGallery
media={attachments}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenMedia={onOpenMedia}
autoPlayGif={autoPlayGif}
/>
);
mediaIcon = 'picture-o';
}
if (
!status.get('sensitive') &&
!(status.get('spoiler_text').length > 0) &&
settings.getIn(['collapsed', 'backgrounds', 'preview_images'])
) background = attachments.getIn([0, 'preview_url']);
}
/*
Finally, we can render our status. We just put the pieces together
from above. We only render the action bar if the status isn't
collapsed.
*/
return (
<article
className={
`status${
muted ? ' muted' : ''
} status-${status.get('visibility')}${
isExpanded === false ? ' collapsed' : ''
}${
isExpanded === false && background ? ' has-background' : ''
}`
}
style={{
backgroundImage: (
isExpanded === false && background ?
`url(${background})` :
'none'
),
}}
ref={handleRef}
>
{prepend && account ? (
<StatusPrepend
type={prepend}
account={account}
parseClick={parseClick}
/>
) : null}
<StatusHeader
account={status.get('account')}
friend={account}
mediaIcon={mediaIcon}
visibility={status.get('visibility')}
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isExpanded === false}
parseClick={parseClick}
setExpansion={setExpansion}
/>
<StatusContent
status={status}
media={media}
mediaIcon={mediaIcon}
expanded={isExpanded}
setExpansion={this.setExpansion}
onHeightUpdate={this.saveHeight}
parseClick={parseClick}
/>
{isExpanded !== false ? (
<StatusActionBar
{...other}
status={status}
account={status.get('account')}
/>
) : null}
</article>
);
}
}

View File

@@ -0,0 +1,164 @@
/*
`<StatusPrepend>`
=================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import { FormattedMessage } from 'react-intl';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
/* * * * */
/*
The `<StatusPrepend>` component:
--------------------------------
The `<StatusPrepend>` component holds a status's prepend, ie the text
that says “X reblogged this,” etc. It is represented by an `<aside>`
element.
### Props
- __`type` (`PropTypes.string`) :__
The type of prepend. One of `'reblogged_by'`, `'reblog'`,
`'favourite'`.
- __`account` (`ImmutablePropTypes.map`) :__
The account associated with the prepend.
- __`parseClick` (`PropTypes.func.isRequired`) :__
Our click parsing function.
*/
export default class StatusPrepend extends React.PureComponent {
static propTypes = {
type: PropTypes.string.isRequired,
account: ImmutablePropTypes.map.isRequired,
parseClick: PropTypes.func.isRequired,
};
/*
### Implementation
#### `handleClick()`.
This is just a small wrapper for `parseClick()` that gets fired when
an account link is clicked.
*/
handleClick = (e) => {
const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`);
}
/*
#### `<Message>`.
`<Message>` is a quick functional React component which renders the
actual prepend message based on our provided `type`. First we create a
`link` for the account's name, and then use `<FormattedMessage>` to
generate the message.
*/
Message = () => {
const { type, account } = this.props;
let link = (
<a
onClick={this.handleClick}
href={account.get('url')}
className='status__display-name'
>
<b
dangerouslySetInnerHTML={{
__html : emojify(escapeTextContentForBrowser(
account.get('display_name') || account.get('username')
)),
}}
/>
</a>
);
switch (type) {
case 'reblogged_by':
return (
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} boosted'
values={{ name : link }}
/>
);
case 'favourite':
return (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favourited your status'
values={{ name : link }}
/>
);
case 'reblog':
return (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={{ name : link }}
/>
);
}
return null;
}
/*
#### `render()`.
Our `render()` is incredibly simple; we just render the icon and then
the `<Message>` inside of an <aside>.
*/
render () {
const { Message } = this;
const { type } = this.props;
return !type ? null : (
<aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}>
<div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
<i
className={`fa fa-fw fa-${
type === 'favourite' ? 'star star-icon' : 'retweet'
} status__prepend-icon`}
/>
</div>
<Message />
</aside>
);
}
}

View File

@@ -0,0 +1,199 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../mastodon/components/icon_button';
import { isIOS } from '../../../mastodon/is_mobile';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
@injectIntl
export default class StatusVideoPlayer extends React.PureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number,
sensitive: PropTypes.bool,
intl: PropTypes.object.isRequired,
autoplay: PropTypes.bool,
onOpenVideo: PropTypes.func.isRequired,
};
static defaultProps = {
height: 110,
};
state = {
visible: !this.props.sensitive,
preview: true,
muted: true,
hasAudio: true,
videoError: false,
};
handleClick = () => {
this.setState({ muted: !this.state.muted });
}
handleVideoClick = (e) => {
e.stopPropagation();
const node = this.video;
if (node.paused) {
node.play();
} else {
node.pause();
}
}
handleOpen = () => {
this.setState({ preview: !this.state.preview });
}
handleVisibility = () => {
this.setState({
visible: !this.state.visible,
preview: true,
});
}
handleExpand = () => {
this.video.pause();
this.props.onOpenVideo(this.props.media, this.video.currentTime);
}
setRef = (c) => {
this.video = c;
}
handleLoadedData = () => {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}
render () {
const { media, intl, letterbox, fullwidth, height, sensitive, autoplay } = this.props;
let spoilerButton = (
<div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
let expandButton = (
<div className='status__video-player-expand'>
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
</div>
);
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div className='status__video-player-mute'>
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) {
if (sensitive) {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
}
if (this.state.preview && !autoplay) {
return (
<div role='button' tabIndex='0' className={`media-spoiler-video ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
{spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</div>
);
}
if (this.state.videoError) {
return (
<div style={{ height: `${height}px` }} className='video-error-cover' >
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return (
<div className={`status__video-player ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px` }}>
{spoilerButton}
{muteButton}
{expandButton}
<video
className={`status__video-player-video${letterbox ? ' letterbox' : ''}`}
role='button'
tabIndex='0'
ref={this.setRef}
src={media.get('url')}
autoPlay={!isIOS()}
loop
muted={this.state.muted}
onClick={this.handleVideoClick}
/>
</div>
);
}
}

View File

@@ -0,0 +1,22 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { changeComposeAdvancedOption } from '../../../mastodon/actions/compose';
// Our imports //
import ComposeAdvancedOptions from '../../components/compose/advanced_options';
const mapStateToProps = state => ({
values: state.getIn(['compose', 'advanced_options']),
});
const mapDispatchToProps = dispatch => ({
onChange (option) {
dispatch(changeComposeAdvancedOption(option));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);

View File

@@ -0,0 +1,21 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { makeGetNotification } from '../../../mastodon/selectors';
// Our imports //
import Notification from '../../components/notification';
const makeMapStateToProps = () => {
const getNotification = makeGetNotification();
const mapStateToProps = (state, props) => ({
notification: getNotification(state, props.notification, props.accountId),
settings: state.get('local_settings'),
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(Notification);

View File

@@ -0,0 +1,27 @@
// Package imports //
import { connect } from 'react-redux';
// Mastodon imports //
import { closeModal } from '../../../mastodon/actions/modal';
// Our imports //
import { changeLocalSetting } from '../../actions/local_settings';
import Settings from '../../components/settings';
const mapStateToProps = state => ({
settings: state.get('local_settings'),
});
const mapDispatchToProps = dispatch => ({
toggleSetting (setting, e) {
dispatch(changeLocalSetting(setting, e.target.checked));
},
changeSetting (setting, e) {
dispatch(changeLocalSetting(setting, e.target.value));
},
onClose () {
dispatch(closeModal());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Settings);

View File

@@ -0,0 +1,252 @@
/*
`<StatusContainer>`
===================
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. Documentation by @kibi@glitch.social. The code
detecting reblogs has been moved here from <Status>.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import { connect } from 'react-redux';
import {
defineMessages,
injectIntl,
FormattedMessage,
} from 'react-intl';
// Mastodon imports //
import { makeGetStatus } from '../../../mastodon/selectors';
import {
replyCompose,
mentionCompose,
} from '../../../mastodon/actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite,
} from '../../../mastodon/actions/interactions';
import {
blockAccount,
muteAccount,
} from '../../../mastodon/actions/accounts';
import {
muteStatus,
unmuteStatus,
deleteStatus,
} from '../../../mastodon/actions/statuses';
import { initReport } from '../../../mastodon/actions/reports';
import { openModal } from '../../../mastodon/actions/modal';
// Our imports //
import Status from '../../components/status';
/* * * * */
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we will
need in our component. In our case, these are the various confirmation
messages used with statuses.
*/
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',
},
});
/* * * * */
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. We wrap this in a `makeMapStateToProps()`
function to give us closure and preserve `getStatus()` across function
calls.
*/
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, ownProps) => {
let status = getStatus(state, ownProps.id);
let reblogStatus = status.get('reblog', null);
let account = undefined;
let prepend = undefined;
/*
Here we process reblogs. If our status is a reblog, then we create a
`prependMessage` to pass along to our `<Status>` along with the
reblogger's `account`, and set `coreStatus` (the one we will actually
render) to the status which has been reblogged.
*/
if (reblogStatus !== null && typeof reblogStatus === 'object') {
account = status.get('account');
status = reblogStatus;
prepend = 'reblogged_by';
}
/*
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']),
};
};
return mapStateToProps;
};
/* * * * */
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We need to provide dispatches for all
of the things you can do with a status: reply, reblog, favourite, et
cetera.
For a few of these dispatches, we open up confirmation modals; the rest
just immediately execute their corresponding actions.
*/
const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch(replyCompose(status, router));
},
onModalReblog (status) {
dispatch(reblog(status));
},
onReblog (status, e) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (e.shiftKey || !this.reblogModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
}
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onDelete (status) {
if (!this.deleteModal) {
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
}));
}
},
onMention (account, router) {
dispatch(mentionCompose(account, router));
},
onOpenMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
},
onOpenVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
},
onBlock (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))),
}));
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
},
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'))),
}));
},
onMuteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
});
export default injectIntl(
connect(makeMapStateToProps, mapDispatchToProps)(Status)
);

View File

@@ -0,0 +1,44 @@
// Package imports //
import Immutable from 'immutable';
// Mastodon imports //
import { STORE_HYDRATE } from '../../mastodon/actions/store';
// Our imports //
import { LOCAL_SETTING_CHANGE } from '../actions/local_settings';
const initialState = Immutable.fromJS({
layout : 'auto',
stretch : true,
collapsed : {
enabled : true,
auto : {
all : false,
notifications : true,
lengthy : true,
replies : false,
media : false,
},
backgrounds : {
user_backgrounds : false,
preview_images : false,
},
},
media : {
letterbox : true,
fullwidth : true,
},
});
const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
export default function localSettings(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('local_settings'));
case LOCAL_SETTING_CHANGE:
return state.setIn(action.key, action.value);
default:
return state;
}
};

View File

@@ -0,0 +1,380 @@
/*********************************************************************\
To my lovely code maintainers,
The syntax recognized by the Mastodon frontend for its bio metadata
feature is a subset of that provided by the YAML 1.2 specification.
In particular, Mastodon recognizes metadata which is provided as an
implicit YAML map, where each key-value pair takes up only a single
line (no multi-line values are permitted). To simplify the level of
processing required, Mastodon metadata frontmatter has been limited
to only allow those characters in the `c-printable` set, as defined
by the YAML 1.2 specification, instead of permitting those from the
`nb-json` characters inside double-quoted strings like YAML proper.
¶ It is important to note that Mastodon only borrows the *syntax*
of YAML, not its semantics. This is to say, Mastodon won't make any
attempt to interpret the data it receives. `true` will not become a
boolean; `56` will not be interpreted as a number. Rather, each key
and every value will be read as a string, and as a string they will
remain. The order of the pairs is unchanged, and any duplicate keys
are preserved. However, YAML escape sequences will be replaced with
the proper interpretations according to the YAML 1.2 specification.
¶ The implementation provided below interprets `<br>` as `\n` and
allows for an open <p> tag at the beginning of the bio. It replaces
the escaped character entities `&apos;` and `&quot;` with single or
double quotes, respectively, prior to processing. However, no other
escaped characters are replaced, not even those which might have an
impact on the syntax otherwise. These minor allowances are provided
because the Mastodon backend will insert these things automatically
into a bio before sending it through the API, so it is important we
account for them. Aside from this, the YAML frontmatter must be the
very first thing in the bio, leading with three consecutive hyphen-
minues (`---`), and ending with the same or, alternatively, instead
with three periods (`...`). No limits have been set with respect to
the number of characters permitted in the frontmatter, although one
should note that only limited space is provided for them in the UI.
¶ The regular expression used to check the existence of, and then
process, the YAML frontmatter has been split into a number of small
components in the code below, in the vain hope that it will be much
easier to read and to maintain. I leave it to the future readers of
this code to determine the extent of my successes in this endeavor.
Sending love + warmth eternal,
- kibigo [@kibi@glitch.social]
\*********************************************************************/
/* CONVENIENCE FUNCTIONS */
const unirex = str => new RegExp(str, 'u');
const rexstr = exp => '(?:' + exp.source + ')';
/* CHARACTER CLASSES */
const DOCUMENT_START = /^/;
const DOCUMENT_END = /$/;
const ALLOWED_CHAR = // `c-printable` in the YAML 1.2 spec.
/[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]/u;
const WHITE_SPACE = /[ \t]/;
const INDENTATION = / */; // Indentation must be only spaces.
const LINE_BREAK = /\r?\n|\r|<br\s*\/?>/;
const ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/;
const HEXADECIMAL_CHARS = /[0-9a-fA-F]/;
const INDICATOR = /[-?:,[\]{}&#*!|>'"%@`]/;
const FLOW_CHAR = /[,[\]{}]/;
/* NEGATED CHARACTER CLASSES */
const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]');
const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]');
const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]');
const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]');
const NOT_ALLOWED_CHAR = unirex(
'(?!' + rexstr(ALLOWED_CHAR) + ')[^]'
);
/* BASIC CONSTRUCTS */
const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*');
const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*');
const NEW_LINE = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
);
const SOME_NEW_LINES = unirex(
'(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+'
);
const POSSIBLE_STARTS = unirex(
rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
);
const POSSIBLE_ENDS = unirex(
rexstr(SOME_NEW_LINES) + '|' +
rexstr(DOCUMENT_END) + '|' +
rexstr(/<\/p>/)
);
const CHARACTER_ESCAPE = unirex(
rexstr(/\\/) +
'(?:' +
rexstr(ESCAPE_CHAR) + '|' +
rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' +
rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' +
rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' +
')'
);
const ESCAPED_CHAR = unirex(
rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' +
rexstr(CHARACTER_ESCAPE)
);
const ANY_ESCAPED_CHARS = unirex(
rexstr(ESCAPED_CHAR) + '*'
);
const ESCAPED_APOS = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
);
const ANY_ESCAPED_APOS = unirex(
rexstr(ESCAPED_APOS) + '*'
);
const FIRST_KEY_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')'
);
const FIRST_VALUE_CHAR = unirex(
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
rexstr(NOT_INDICATOR) + '|' +
rexstr(/[?:-]/) +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')'
// Flow indicators are allowed in values.
);
const LATER_KEY_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
'(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
);
const LATER_VALUE_CHAR = unirex(
rexstr(WHITE_SPACE) + '|' +
'(?=' + rexstr(NOT_LINE_BREAK) + ')' +
'(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
// Flow indicators are allowed in values.
rexstr(/[^:#]#?/) + '|' +
rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
);
/* YAML CONSTRUCTS */
const YAML_START = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/---/)
);
const YAML_END = unirex(
rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/)
);
const YAML_LOOKAHEAD = unirex(
'(?=' +
rexstr(YAML_START) +
rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +
')'
);
const YAML_DOUBLE_QUOTE = unirex(
rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/)
);
const YAML_SINGLE_QUOTE = unirex(
rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/)
);
const YAML_SIMPLE_KEY = unirex(
rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
);
const YAML_SIMPLE_VALUE = unirex(
rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
);
const YAML_KEY = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_KEY)
);
const YAML_VALUE = unirex(
rexstr(YAML_DOUBLE_QUOTE) + '|' +
rexstr(YAML_SINGLE_QUOTE) + '|' +
rexstr(YAML_SIMPLE_VALUE)
);
const YAML_SEPARATOR = unirex(
rexstr(ANY_WHITE_SPACE) +
':' + rexstr(WHITE_SPACE) +
rexstr(ANY_WHITE_SPACE)
);
const YAML_LINE = unirex(
'(' + rexstr(YAML_KEY) + ')' +
rexstr(YAML_SEPARATOR) +
'(' + rexstr(YAML_VALUE) + ')'
);
/* FRONTMATTER REGEX */
const YAML_FRONTMATTER = unirex(
rexstr(POSSIBLE_STARTS) +
rexstr(YAML_LOOKAHEAD) +
rexstr(YAML_START) + rexstr(SOME_NEW_LINES) +
'(?:' +
'(' + rexstr(INDENTATION) + ')' +
rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'(?:' +
'\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
'){0,4}' +
')?' +
rexstr(YAML_END) + rexstr(POSSIBLE_ENDS)
);
/* SEARCHES */
const FIND_YAML_LINES = unirex(
rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE)
);
/* STRING PROCESSING */
function processString(str) {
switch (str.charAt(0)) {
case '"':
return str
.substring(1, str.length - 1)
.replace(/\\0/g, '\x00')
.replace(/\\a/g, '\x07')
.replace(/\\b/g, '\x08')
.replace(/\\t/g, '\x09')
.replace(/\\\x09/g, '\x09')
.replace(/\\n/g, '\x0a')
.replace(/\\v/g, '\x0b')
.replace(/\\f/g, '\x0c')
.replace(/\\r/g, '\x0d')
.replace(/\\e/g, '\x1b')
.replace(/\\ /g, '\x20')
.replace(/\\"/g, '\x22')
.replace(/\\\//g, '\x2f')
.replace(/\\\\/g, '\x5c')
.replace(/\\N/g, '\x85')
.replace(/\\_/g, '\xa0')
.replace(/\\L/g, '\u2028')
.replace(/\\P/g, '\u2029')
.replace(
new RegExp(
unirex(
rexstr(/\\x/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{2})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
)
.replace(
new RegExp(
unirex(
rexstr(/\\u/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{4})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
)
.replace(
new RegExp(
unirex(
rexstr(/\\U/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{8})'
), 'gu'
), (_, n) => String.fromCodePoint('0x' + n)
);
case '\'':
return str
.substring(1, str.length - 1)
.replace(/''/g, '\'');
default:
return str;
}
}
/* BIO PROCESSING */
export function processBio(content) {
content = content.replace(/&quot;/g, '"').replace(/&apos;/g, '\'');
let result = {
text: content,
metadata: [],
};
let yaml = content.match(YAML_FRONTMATTER);
if (!yaml) return result;
else yaml = yaml[0];
let start = content.search(YAML_START);
let end = start + yaml.length - yaml.search(YAML_START);
result.text = content.substr(0, start) + content.substr(end);
let metadata = null;
let query = new RegExp(FIND_YAML_LINES, 'g');
while ((metadata = query.exec(yaml))) {
result.metadata.push([
processString(metadata[1]),
processString(metadata[2]),
]);
}
return result;
}
/* BIO CREATION */
export function createBio(note, data) {
if (!note) note = '';
let frontmatter = '';
if ((data && data.length) || note.match(/^\s*---\s+/)) {
if (!data) frontmatter = '---\n...\n';
else {
frontmatter += '---\n';
for (let i = 0; i < data.length; i++) {
let key = '' + data[i][0];
let val = '' + data[i][1];
// Key processing
if (key === (key.match(YAML_SIMPLE_KEY) || [])[0]) /* do nothing */;
else if (key.indexOf('\'') === -1 && key === (key.match(ANY_ESCAPED_APOS) || [])[0]) key = '\'' + key + '\'';
else {
key = key
.replace(/\x00/g, '\\0')
.replace(/\x07/g, '\\a')
.replace(/\x08/g, '\\b')
.replace(/\x0a/g, '\\n')
.replace(/\x0b/g, '\\v')
.replace(/\x0c/g, '\\f')
.replace(/\x0d/g, '\\r')
.replace(/\x1b/g, '\\e')
.replace(/\x22/g, '\\"')
.replace(/\x5c/g, '\\\\');
let badchars = key.match(
new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu')
) || [];
for (let j = 0; j < badchars.length; j++) {
key = key.replace(
badchars[i],
'\\u' + badchars[i].codePointAt(0).toLocaleString('en', {
useGrouping: false,
minimumIntegerDigits: 4,
})
);
}
key = '"' + key + '"';
}
// Value processing
if (val === (val.match(YAML_SIMPLE_VALUE) || [])[0]) /* do nothing */;
else if (val.indexOf('\'') === -1 && val === (val.match(ANY_ESCAPED_APOS) || [])[0]) val = '\'' + val + '\'';
else {
val = val
.replace(/\x00/g, '\\0')
.replace(/\x07/g, '\\a')
.replace(/\x08/g, '\\b')
.replace(/\x0a/g, '\\n')
.replace(/\x0b/g, '\\v')
.replace(/\x0c/g, '\\f')
.replace(/\x0d/g, '\\r')
.replace(/\x1b/g, '\\e')
.replace(/\x22/g, '\\"')
.replace(/\x5c/g, '\\\\');
let badchars = val.match(
new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu')
) || [];
for (let j = 0; j < badchars.length; j++) {
val = val.replace(
badchars[i],
'\\u' + badchars[i].codePointAt(0).toLocaleString('en', {
useGrouping: false,
minimumIntegerDigits: 4,
})
);
}
val = '"' + val + '"';
}
frontmatter += key + ': ' + val + '\n';
}
frontmatter += '...\n';
}
}
return frontmatter + note;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" height="1000" width="1000"><path d="M500 0a500 500 0 0 0-353.553 146.447 500 500 0 1 0 707.106 707.106A500 500 0 0 0 500 0zm-.059 280.05h107.12c-19.071 13.424-26.187 51.016-27.12 73.843V562.05c0 44.32-35.68 80-80 80s-80-35.68-80-80v-202c0-44.32 35.68-80 80-80zm-.441 52c-15.464 0-28 12.537-28 28 0 15.465 12.536 28 28 28s28-12.535 28-28c0-15.463-12.536-28-28-28zm-279.059 7.9c44.32 0 80 35.68 80 80v206.157c.933 22.827 8.049 60.42 27.12 73.842H220.44c-44.32 0-80-35.68-80-80v-200c0-44.32 35.68-80 80-80zm559.12 0c44.32 0 80 35.68 80 80v200c0 44.32-35.68 80-80 80H672.44c19.071-13.424 26.187-51.016 27.12-73.843V419.95c0-44.32 35.68-80 80-80zM220 392c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm560 0c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm-280.5 40.05c-15.464 0-28 12.537-28 28 0 15.465 12.536 28 28 28s28-12.535 28-28c0-15.463-12.536-28-28-28zM220 491.95c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28zm560 0c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28zM499.5 532c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zM220 591.95c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28zm560 0c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28z" fill="#189efc"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" height="1000" width="1000"><path d="M500 0a500 500 0 0 0-353.553 146.447 500 500 0 1 0 707.106 707.106A500 500 0 0 0 500 0zm-.059 280.05h107.12c-19.071 13.424-26.187 51.016-27.12 73.843V562.05c0 44.32-35.68 80-80 80s-80-35.68-80-80v-202c0-44.32 35.68-80 80-80zm-.441 52c-15.464 0-28 12.537-28 28 0 15.465 12.536 28 28 28s28-12.535 28-28c0-15.463-12.536-28-28-28zm-279.059 7.9c44.32 0 80 35.68 80 80v206.157c.933 22.827 8.049 60.42 27.12 73.842H220.44c-44.32 0-80-35.68-80-80v-200c0-44.32 35.68-80 80-80zm559.12 0c44.32 0 80 35.68 80 80v200c0 44.32-35.68 80-80 80H672.44c19.071-13.424 26.187-51.016 27.12-73.843V419.95c0-44.32 35.68-80 80-80zM220 392c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm560 0c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zm-280.5 40.05c-15.464 0-28 12.537-28 28 0 15.465 12.536 28 28 28s28-12.535 28-28c0-15.463-12.536-28-28-28zM220 491.95c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28zm560 0c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28zM499.5 532c-15.464 0-28 12.536-28 28s12.536 28 28 28 28-12.536 28-28-12.536-28-28-28zM220 591.95c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28zm560 0c-15.464 0-28 12.535-28 28 0 15.463 12.536 28 28 28s28-12.537 28-28c0-15.465-12.536-28-28-28z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,5 +1,4 @@
import api, { getLinks } from '../api';
import Immutable from 'immutable';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
@@ -597,7 +596,7 @@ export function authorizeFollowRequest(id) {
api(getState)
.post(`/api/v1/follow_requests/${id}/authorize`)
.then(response => dispatch(authorizeFollowRequestSuccess(id)))
.then(() => dispatch(authorizeFollowRequestSuccess(id)))
.catch(error => dispatch(authorizeFollowRequestFail(id, error)));
};
};
@@ -631,7 +630,7 @@ export function rejectFollowRequest(id) {
api(getState)
.post(`/api/v1/follow_requests/${id}/reject`)
.then(response => dispatch(rejectFollowRequestSuccess(id)))
.then(() => dispatch(rejectFollowRequestSuccess(id)))
.catch(error => dispatch(rejectFollowRequestFail(id, error)));
};
};

View File

@@ -0,0 +1,25 @@
export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
export function fetchBundleRequest(skipLoading) {
return {
type: BUNDLE_FETCH_REQUEST,
skipLoading,
};
}
export function fetchBundleSuccess(skipLoading) {
return {
type: BUNDLE_FETCH_SUCCESS,
skipLoading,
};
}
export function fetchBundleFail(error, skipLoading) {
return {
type: BUNDLE_FETCH_FAIL,
error,
skipLoading,
};
}

View File

@@ -24,6 +24,7 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
@@ -73,11 +74,14 @@ export function mentionCompose(account, router) {
export function submitCompose() {
return function (dispatch, getState) {
const status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], ''));
let status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], ''));
if (!status || !status.length) {
return;
}
dispatch(submitComposeRequest());
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
status = status + ' 👁️';
}
api(getState).post('/api/v1/statuses', {
status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
@@ -244,6 +248,13 @@ export function unmountCompose() {
};
};
export function changeComposeAdvancedOption(option) {
return {
type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
option: option,
};
}
export function changeComposeSensitivity() {
return {
type: COMPOSE_SENSITIVITY_CHANGE,

View File

@@ -16,7 +16,7 @@ export function blockDomain(domain, accountId) {
return (dispatch, getState) => {
dispatch(blockDomainRequest(domain));
api(getState).post('/api/v1/domain_blocks', { domain }).then(response => {
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
dispatch(blockDomainSuccess(domain, accountId));
}).catch(err => {
dispatch(blockDomainFail(domain, err));
@@ -51,7 +51,7 @@ export function unblockDomain(domain, accountId) {
return (dispatch, getState) => {
dispatch(unblockDomainRequest(domain));
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(response => {
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
dispatch(unblockDomainSuccess(domain, accountId));
}).catch(err => {
dispatch(unblockDomainFail(domain, err));

View File

@@ -1,5 +1,5 @@
import api, { getLinks } from '../api';
import Immutable from 'immutable';
import { List as ImmutableList } from 'immutable';
import IntlMessageFormat from 'intl-messageformat';
import { fetchRelationships } from './accounts';
import { defineMessages } from 'react-intl';
@@ -17,7 +17,7 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
const messages = defineMessages({
defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
});
@@ -124,7 +124,7 @@ export function refreshNotificationsFail(error, skipLoading) {
export function expandNotifications() {
return (dispatch, getState) => {
const items = getState().getIn(['notifications', 'items'], Immutable.List());
const items = getState().getIn(['notifications', 'items'], ImmutableList());
if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
return;

View File

@@ -1,4 +1,5 @@
import api from '../api';
import { openModal, closeModal } from './modal';
export const REPORT_INIT = 'REPORT_INIT';
export const REPORT_CANCEL = 'REPORT_CANCEL';
@@ -11,10 +12,14 @@ export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
export function initReport(account, status) {
return {
type: REPORT_INIT,
account,
status,
return dispatch => {
dispatch({
type: REPORT_INIT,
account,
status,
});
dispatch(openModal('REPORT'));
};
};
@@ -40,7 +45,10 @@ export function submitReport() {
account_id: getState().getIn(['reports', 'new', 'account_id']),
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
comment: getState().getIn(['reports', 'new', 'comment']),
}).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error)));
}).then(response => {
dispatch(closeModal());
dispatch(submitReportSuccess(response.data));
}).catch(error => dispatch(submitReportFail(error)));
};
};

View File

@@ -74,7 +74,7 @@ export function deleteStatus(id) {
return (dispatch, getState) => {
dispatch(deleteStatusRequest(id));
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
}).catch(error => {
@@ -152,7 +152,7 @@ export function muteStatus(id) {
return (dispatch, getState) => {
dispatch(muteStatusRequest(id));
api(getState).post(`/api/v1/statuses/${id}/mute`).then(response => {
api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
dispatch(muteStatusSuccess(id));
}).catch(error => {
dispatch(muteStatusFail(id, error));
@@ -186,7 +186,7 @@ export function unmuteStatus(id) {
return (dispatch, getState) => {
dispatch(unmuteStatusRequest(id));
api(getState).post(`/api/v1/statuses/${id}/unmute`).then(response => {
api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
dispatch(unmuteStatusSuccess(id));
}).catch(error => {
dispatch(unmuteStatusFail(id, error));

View File

@@ -1,10 +1,11 @@
import Immutable from 'immutable';
import { Iterable, fromJS } from 'immutable';
export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
const convertState = rawState =>
Immutable.fromJS(rawState, (k, v) =>
Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
fromJS(rawState, (k, v) =>
Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
Number.isNaN(x * 1) ? x : x * 1));
export function hydrateStore(rawState) {

View File

@@ -1,5 +1,5 @@
import api, { getLinks } from '../api';
import Immutable from 'immutable';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@@ -66,13 +66,13 @@ export function refreshTimelineRequest(timeline, skipLoading) {
export function refreshTimeline(timelineId, path, params = {}) {
return function (dispatch, getState) {
const timeline = getState().getIn(['timelines', timelineId], Immutable.Map());
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
if (timeline.get('isLoading') || timeline.get('online')) {
return;
}
const ids = timeline.get('items', Immutable.List());
const ids = timeline.get('items', ImmutableList());
const newestId = ids.size > 0 ? ids.first() : null;
let skipLoading = timeline.get('loaded');
@@ -111,8 +111,8 @@ export function refreshTimelineFail(timeline, error, skipLoading) {
export function expandTimeline(timelineId, path, params = {}) {
return (dispatch, getState) => {
const timeline = getState().getIn(['timelines', timelineId], Immutable.Map());
const ids = timeline.get('items', Immutable.List());
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
const ids = timeline.get('items', ImmutableList());
if (timeline.get('isLoading') || ids.size === 0) {
return;

View File

@@ -16,7 +16,8 @@ const messages = defineMessages({
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
});
class Account extends ImmutablePureComponent {
@injectIntl
export default class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
@@ -82,5 +83,3 @@ class Account extends ImmutablePureComponent {
}
}
export default injectIntl(Account);

View File

@@ -4,7 +4,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
class AttachmentList extends ImmutablePureComponent {
export default class AttachmentList extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.list.isRequired,
@@ -31,5 +31,3 @@ class AttachmentList extends ImmutablePureComponent {
}
}
export default AttachmentList;

View File

@@ -31,7 +31,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
}
};
class AutosuggestTextarea extends ImmutablePureComponent {
export default class AutosuggestTextarea extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
@@ -196,5 +196,3 @@ class AutosuggestTextarea extends ImmutablePureComponent {
}
}
export default AutosuggestTextarea;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
class Avatar extends React.PureComponent {
export default class Avatar extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
@@ -66,5 +66,3 @@ class Avatar extends React.PureComponent {
}
}
export default Avatar;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
class AvatarOverlay extends React.PureComponent {
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
staticSrc: PropTypes.string.isRequired,
@@ -28,5 +28,3 @@ class AvatarOverlay extends React.PureComponent {
}
}
export default AvatarOverlay;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
class Button extends React.PureComponent {
export default class Button extends React.PureComponent {
static propTypes = {
text: PropTypes.node,
@@ -61,5 +61,3 @@ class Button extends React.PureComponent {
}
}
export default Button;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import scrollTop from '../scroll';
class Column extends React.PureComponent {
export default class Column extends React.PureComponent {
static propTypes = {
children: PropTypes.node,
@@ -41,5 +41,3 @@ class Column extends React.PureComponent {
}
}
export default Column;

View File

@@ -2,15 +2,19 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
class ColumnBackButton extends React.PureComponent {
export default class ColumnBackButton extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
handleClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
render () {
@@ -23,5 +27,3 @@ class ColumnBackButton extends React.PureComponent {
}
}
export default ColumnBackButton;

View File

@@ -2,15 +2,19 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
class ColumnBackButtonSlim extends React.PureComponent {
export default class ColumnBackButtonSlim extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
handleClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
render () {
@@ -25,5 +29,3 @@ class ColumnBackButtonSlim extends React.PureComponent {
}
}
export default ColumnBackButtonSlim;

View File

@@ -1,52 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
class ColumnCollapsable extends React.PureComponent {
static propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string,
fullHeight: PropTypes.number.isRequired,
children: PropTypes.node,
onCollapse: PropTypes.func,
};
state = {
collapsed: true,
animating: false,
};
handleToggleCollapsed = () => {
const currentState = this.state.collapsed;
this.setState({ collapsed: !currentState, animating: true });
if (!currentState && this.props.onCollapse) {
this.props.onCollapse();
}
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
render () {
const { icon, title, fullHeight, children } = this.props;
const { collapsed, animating } = this.state;
return (
<div className={`column-collapsable ${collapsed ? 'collapsed' : ''}`} onTransitionEnd={this.handleTransitionEnd}>
<div role='button' tabIndex='0' title={`${title}`} className='column-collapsable__button column-icon' onClick={this.handleToggleCollapsed}>
<i className={`fa fa-${icon}`} />
</div>
<div className='column-collapsable__content' style={{ height: `${fullHeight}px` }}>
{(!collapsed || animating) && children}
</div>
</div>
);
}
}
export default ColumnCollapsable;

View File

@@ -3,14 +3,14 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
class ColumnHeader extends React.PureComponent {
export default class ColumnHeader extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
title: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
icon: PropTypes.string.isRequired,
active: PropTypes.bool,
multiColumn: PropTypes.bool,
@@ -45,8 +45,12 @@ class ColumnHeader extends React.PureComponent {
}
handleBackClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
handleTransitionEnd = () => {
@@ -132,7 +136,7 @@ class ColumnHeader extends React.PureComponent {
</div>
<div className={collapsibleClassName} onTransitionEnd={this.handleTransitionEnd}>
<div>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
</div>
</div>
@@ -141,5 +145,3 @@ class ColumnHeader extends React.PureComponent {
}
}
export default ColumnHeader;

View File

@@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import emojify from '../emoji';
class DisplayName extends React.PureComponent {
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
@@ -21,5 +21,3 @@ class DisplayName extends React.PureComponent {
}
}
export default DisplayName;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
import PropTypes from 'prop-types';
class DropdownMenu extends React.PureComponent {
export default class DropdownMenu extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
@@ -14,6 +14,7 @@ class DropdownMenu extends React.PureComponent {
size: PropTypes.number.isRequired,
direction: PropTypes.string,
ariaLabel: PropTypes.string,
disabled: PropTypes.bool,
};
static defaultProps = {
@@ -56,7 +57,7 @@ class DropdownMenu extends React.PureComponent {
return <li key={`sep-${i}`} className='dropdown__sep' />;
}
const { text, action, href = '#' } = item;
const { text, href = '#' } = item;
return (
<li className='dropdown__content-list-item' key={`${text}-${i}`}>
@@ -68,9 +69,19 @@ class DropdownMenu extends React.PureComponent {
}
render () {
const { icon, items, size, direction, ariaLabel } = this.props;
const { expanded } = this.state;
const { icon, items, size, direction, ariaLabel, disabled } = this.props;
const { expanded } = this.state;
const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right';
const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` };
const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`;
if (disabled) {
return (
<div className='icon-button disabled' style={iconStyle} aria-label={ariaLabel}>
<i className={iconClassname} aria-hidden />
</div>
);
}
const dropdownItems = expanded && (
<ul className='dropdown__content-list'>
@@ -80,8 +91,8 @@ class DropdownMenu extends React.PureComponent {
return (
<Dropdown ref={this.setRef} onShow={this.handleShow} onHide={this.handleHide}>
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }} aria-label={ariaLabel}>
<i className={`fa fa-fw fa-${icon} dropdown__icon`} aria-hidden />
<DropdownTrigger className='icon-button' style={iconStyle} aria-label={ariaLabel}>
<i className={iconClassname} aria-hidden />
</DropdownTrigger>
<DropdownContent className={directionClass}>
@@ -92,5 +103,3 @@ class DropdownMenu extends React.PureComponent {
}
}
export default DropdownMenu;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
class ExtendedVideoPlayer extends React.PureComponent {
export default class ExtendedVideoPlayer extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
@@ -44,5 +44,3 @@ class ExtendedVideoPlayer extends React.PureComponent {
}
}
export default ExtendedVideoPlayer;

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