Compare commits

..

266 Commits

Author SHA1 Message Date
Eugen Rochko
4819e2913d Bump version to 1.6.0rc5 2017-09-10 10:26:51 +02:00
Eugen Rochko
72e662bb0d Hide modal loading screen for media/video/boost/confirm/actions modals (#4873) 2017-09-10 10:26:01 +02:00
Eugen Rochko
7d7844a47f Default follows for new users (#4871)
When a new user confirms their e-mail, bootstrap their home timeline
by automatically following a set of accounts. By default, all local
admin accounts (that are unlocked). Can be customized by new admin
setting (comma-separated usernames, local and unlocked only)
2017-09-10 09:58:38 +02:00
Eugen Rochko
f2cbfb2eb3 Fix dimensions of loading component for compose drawer (#4872) 2017-09-10 08:48:11 +02:00
Yamagishi Kazutoshi
3f333a8d31 Set fallback address when empty notification address (#4868) 2017-09-09 21:31:48 +02:00
Quent-in
bc077018b8 i10n minors changes for 1.6 (#4867)
* wrong preposition + typo

* wrong preposition + typo

* Typo

* Typo

* minor changes

* minor changes
2017-09-09 18:22:56 +02:00
Eugen Rochko
90712d4293 Fix errors preventing UnsubscribeService from working (#4866) 2017-09-09 17:36:27 +02:00
Eugen Rochko
6867681c7c Add script to make embedded iframes autosize (#4853) 2017-09-09 16:23:44 +02:00
Eugen Rochko
bdc8b4fd91 Disable mouse-based pause from #4859 (#4865)
It wasn't working ideally and introduced some annoying false positivies
2017-09-09 15:09:50 +02:00
Eugen Rochko
2ff7146b6d Bump version to 1.6.0rc4 2017-09-09 14:53:49 +02:00
unarist
c7908e2d09 Fix scroll behavior and others on paused timeline (#4864)
Resolved:

* Lot of redundant renders while mouse moving
* Scroll jumping when timeline loaded
* Scroll position isn't kept when statuses below the scrollTop was deleted then new status arrived

Unresolved:

* Scroll position isn't kept when statuses over the scrollTop was deleted then new status arrived
-> It needs to know which statuses are over the scrollTop
* New status indicator should be active when new statuses arrived while mouse moved recently
-> It needs a) update indicator in ScrollableList, or b) set scrollTop status while mouse moving
2017-09-09 14:16:11 +02:00
Yamagishi Kazutoshi
c9d04f1c39 Fix second report (regression from 3b81baaaaf) (#4863) 2017-09-09 13:42:48 +02:00
Eugen Rochko
9e15eeec63 Add missing reject_media check before avatar download via ActivityPub (#4862) 2017-09-09 13:41:45 +02:00
Lynx Kotoura
3c45d3963a Scrollable tables in settings pages (#4857)
* Scrollable tables in settings pages

* Add space before curly brace
2017-09-09 02:26:58 +02:00
Eugen Rochko
baa8b82179 Fix #1004 - Temporarily pause timeline if there's been recent mouse movement (#4859) 2017-09-09 02:26:41 +02:00
Eugen Rochko
4b460bc571 Fix #4852 - Check if already requested from FollowService (#4855) 2017-09-09 02:02:44 +02:00
Eugen Rochko
7ca173be47 Fix #4850 - When visibility missing from API call to toot, fallback to user preference (#4861) 2017-09-09 02:02:29 +02:00
unarist
1ae5d49a71 Refresh timeline after toot while the timeline is disconnected (#4858)
To reflect status posting immediately, we've inserted the status into timelines directly. However, status insertion changes "latest status", and it means next timeline refresh only fetches statuses since the inserted status. This behavior is very bad for disconnected timeline and mobile views.

After this patch, it refreshes timeline for disconnected timelines, instead of direct insertion.
2017-09-08 21:43:34 +02:00
unarist
a12572e074 Handle stream_entry URL correctly in ActivityPub (#4854)
In before, the method uses stream_entry id as status id, so replied status was wrongly selected.

This PR uses StatusFinder which was introduced with `Api::Web::EmbedsController`.
2017-09-08 18:20:03 +02:00
Quent-in
dabc309ca3 i10n update OC and FR (#4849)
* Missing "navigation_bar.pins"

* Missing "navigation_bar.pins"
2017-09-08 13:55:47 +02:00
Eugen Rochko
1caf11ddcc Fix language filter codes (#4841)
* Fix language filter codes

CLD3 returns BCP-47 language identifier, filter settings expect
identifiers in the ISO 639-1 format. Convert between formats,
and exclude duplicate languages from filter choices (zh-CN->zh)

* Fix zh name
2017-09-08 12:32:22 +02:00
Eugen Rochko
95f018a3d4 "Mute conversation" option on all own toots, not just in notifications (#4844)
That way you can mute notifications for a toot before you get replies
to it or boosts or favourites
2017-09-08 12:00:30 +02:00
Eugen Rochko
a4caa7eb62 Fetch statuses/following/followers numbers from ActivityPub collections (#4840) 2017-09-08 12:00:17 +02:00
m4sk1n
7c2d84910c i18n: Update Polish translation (#4845)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-09-08 05:51:48 +09:00
Quent-in
b00cc4b9bd i10n OC / FR update Pinned toots (#4842)
* Added column.pins

New strings

* Added column.pins

* Update confirmation_instructions.oc.html.erb

* Update confirmation_instructions.oc.text.erb

* Update password_change.oc.html.erb

* Update password_change.oc.text.erb

* Update reset_password_instructions.oc.html.erb

* Update reset_password_instructions.oc.text.erb

* Update confirmation_instructions.oc.html.erb

* Update confirmation_instructions.oc.text.erb
2017-09-07 22:07:03 +02:00
Eugen Rochko
dd6ede554f Fix #4834 - Adjust Status#local and Status#remote scopes (#4839) 2017-09-07 20:18:34 +02:00
abcang
6859d4c028 Enable UniqueRetryJobMiddleware even when called from sidekiq worker (#4836) 2017-09-07 16:44:14 +02:00
PFM
7d853b514a Use <button> instead of <div role="button"> (#4835) 2017-09-07 16:18:41 +02:00
voidSatisfaction
85c7c42098 Add Pinned toot column (#4817)
* Add Pinned_toot_section

* Fix add frozen_string_literal

* Fix delete no need controller and tests

* Fix replace query strings to axios params

* Fix change value to accountId and disabling more button
2017-09-07 09:58:11 +02:00
voidSatisfaction
8185f98872 Feat add validation for report comment: characters under 1000 valid (#4833) 2017-09-07 09:55:42 +02:00
Joseph Mingrone
5264496240 Use casecmp() instead of casecmp?() for now (#4832)
* Use casecmp() instead of casecmp?() for now

casecmp?() is only available in ruby 2.4.0.  Users running earlier ruby versions
would see errors, e.g., running
RAILS_ENV=production rails mastodon:maintenance:remove_deprecated_preview_cards.

* Correctly check whether casecmp() returns 0
2017-09-07 03:55:06 +02:00
Quent-in
be75b13d68 i10n update OC and FR files (#4824)
* Onboarding: corrections

Some missing letters and spaces or better wording

* Embed

Translated as Intégrer in FR / Embarcar in OC
2017-09-07 08:55:03 +09:00
Olivier Humbert
9417c9bb8f Update fr.json (#4830)
typo
2017-09-06 22:32:49 +02:00
Eugen Rochko
11bddd31ce Fix locking migration on statuses table. Nullable column and NO default value (#4825) 2017-09-06 20:57:52 +02:00
Eugen Rochko
dd5cb5085c Bump version to 1.6.0rc2 2017-09-06 19:02:03 +02:00
Eugen Rochko
e7adbf572a Switch to static URIs, new URI format in both protocols for new statuses (#4815)
* Decouple Status#local? from uri being nil

* Replace on-the-fly URI generation with stored URIs

- Generate URI in after_save hook for local statuses
- Use static value in TagManager when available, fallback to tag format
- Make TagManager use ActivityPub::TagManager to understand new format
- Adjust tests

* Use other heuristic for locality of old statuses, do not perform long query

* Exclude tombstone stream entries from Atom feed

* Prevent nil statuses from landing in Pubsubhubbub::DistributionWorker

* Fix URI not being saved (#4818)

* Add more specs for Status

* Save generated uri immediately

and also fix method order to minimize diff.

* Fix alternate HTML URL in Atom

* Fix tests

* Remove not-null constraint from statuses migration to speed it up
2017-09-06 19:01:28 +02:00
voidSatisfaction
13ffa3c59e Add Smartphone screen favourite back button and adjust styles (#4813)
* Feat add get-back button on favourite columnHeader

* Style adjust nice looking get-back button

* Fix delete media query and add padding right

* fix: restore padding and add lastchild style for back-button
2017-09-06 17:32:15 +02:00
Masoud Abkenar
aec5097d44 i18n: update Persian translation (#4822) 2017-09-06 17:31:54 +02:00
Clworld
1646f622a5 fix scroll position (#4821) 2017-09-06 17:29:56 +02:00
Yamagishi Kazutoshi
e0cda4a851 Update react-intl to version 2.4.0 (#4820) 2017-09-06 16:25:19 +02:00
PFM
d8d2a54741 fix text position of NSFW for video file (#4819) 2017-09-06 13:55:47 +02:00
Yamagishi Kazutoshi
fa21d004c7 Add environment sample for OpenStack Swift (#4816) 2017-09-06 12:13:00 +02:00
Adam Thurlow
6994664a13 swift-enable the paperclip! 📎 (#2322) 2017-09-05 23:17:06 +02:00
abcang
be7ffa2d75 Do not execute the job with the same arguments as the retry job (#4814) 2017-09-05 20:56:20 +02:00
Eugen Rochko
e821c00e74 Fix mentions in direct statuses not being delivered via AP (#4806) 2017-09-05 20:55:25 +02:00
Eugen Rochko
9b994c4aee Fix #4794 - Fake instant follow in API response when account is believed unlocked (#4799) 2017-09-05 17:48:13 +02:00
Lynx Kotoura
4c3dd0b254 Adjust status embeds (#4808)
* Adjust status embeds

Adjust styles of embed code. Adjust styles of embed pages. Fix overflow of embed-modal.

* Remove trailing whitespace

* Using width from the variable
2017-09-05 12:31:24 +02:00
takayamaki
672df4ecc0 add index_notifications_on_id_and_account_id_and_activity_type on notifications table (#4750) 2017-09-05 12:30:01 +02:00
Treyssat-Vincent Nino
aefb4719bc comment correction (#4812) 2017-09-05 12:13:25 +02:00
voidSatisfaction
4d67bf18fe Translation korean added (#4802) 2017-09-04 20:40:01 +02:00
ButterflyOfFire
f09a250a7c Update ar.yml (#4810)
Some little changes to "ar" locale
2017-09-04 19:54:12 +02:00
Eugen Rochko
9b50a9dd83 Fix some ActivityPub JSON bugs (#4796)
- Fix assumption that `url` is always a string. Handle it if it's an
  array of strings, array of objects, object, or string, both for
  accounts and for objects
- `sharedInbox` is actually supposed to be under `endpoints`, handle
  both cases and adjust the serializer
2017-09-04 18:26:33 +02:00
nullkal
2293466edd Show pinned statuses only in the top of the profile page (#4803)
* Show pinned statuses only in the top of the profile page

* Refactor AccountsController#show_pinned_statuses?
2017-09-04 12:53:18 +02:00
voidSatisfaction
b6f3869f8d Fix streaming url to lowercase (#4804) 2017-09-04 12:52:06 +02:00
Eugen Rochko
09cffaaf04 Fix #4551 - Use correct syntax for content preloading (#4798) 2017-09-04 02:14:12 +02:00
abcang
334a633c2a Fix a problem that notification column goes to top (#4792) 2017-09-03 20:31:51 +02:00
Eugen Rochko
8b12e3cc7f Fix short number locales (#4790)
Overwrite values from rails-i18n by manually overwriting in every locale.
We want numbers like 1.5K in every language
2017-09-03 22:17:34 +09:00
Andreas Drop
d3f46a77c3 Make german translation more gender neutral #4755 (#4789) 2017-09-03 15:17:24 +02:00
Akihiko Odaki
a789315361 Use next instead of return in task (#4787) 2017-09-03 12:39:39 +02:00
May Kittens Devour Your Soul
579c7a88e0 Croatian translation - updated (#4183)
* Update hr.json

* Update hr.json
2017-09-03 18:10:53 +09:00
Quent-in
8538170c2d l10n Occitan update for Embed, cancel follow request, ... (#4788)
* Update: some missing strings

* Updates missing strings

* New string

* Update oc.json

* Update oc.yml

* Update oc.json
2017-09-03 18:08:37 +09:00
Akihiko Odaki
249bdc169c Explicitly define attached file of DeprecatedPreviewCard (#4786)
The path template of the attached files must explicitly be defined because
it is contradicting to the name of the class.
2017-09-03 07:58:58 +02:00
Eugen Rochko
9dd8dff683 Bump version to 1.6.0rc1 (#4768) 2017-09-03 01:12:05 +02:00
Eugen Rochko
a187dcefa1 Instantly upgrade account to ActivityPub if we receive ActivityPub payload (#4766) 2017-09-03 01:11:23 +02:00
Eugen Rochko
5d170587e3 Improve client-side German i18n (#4785) 2017-09-02 23:21:48 +02:00
Eugen Rochko
37fdddd927 Rename "locked" to "manuallyApprovesFollowers" in ActivityPub (#4779)
See: <https://www.w3.org/wiki/Activity_Streams_extensions#as:manuallyApprovesFollowers>
2017-09-02 23:13:35 +02:00
abcang
6ec1aa372d Validate data of Imports (#4782) 2017-09-02 20:45:42 +02:00
m4sk1n
2c3544eedd i18n: Improve Polish translation (#4783)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-09-02 20:45:20 +02:00
Eugen Rochko
d3b6746173 Make "unfollow" undo pending outgoing follow request too (#4781)
* Make "unfollow" undo pending outgoing follow request too

* Add cancel button to web UI when awaiting follow request approval

* Make the hourglass button do the cancelling
2017-09-02 20:44:41 +02:00
PFM
2a5d1d5a1b fix text position of NSFW in Safari (Mac/iPhone) (#4570) 2017-09-02 18:52:56 +02:00
unarist
6a4e2db661 Raise an error for remote url in StatusFinder (#4776)
* Raise an error for remote url in StatusFinder

Previous implementation had allowed remote url with status id which also exists on local.

Then that bug leads /api/web/embed to return wrong embed url.

* Fix oembed_controller_spec
2017-09-02 17:42:47 +02:00
unarist
bfa7f9ebf2 Fix WebPush (regression from #4524) (#4778)
* Fix NoMethodError in Web::PushSubscription

```
undefined method `site_contact_email' for #<Class:0x00005976d13c40>

/usr/local/bundle/gems/activerecord-5.1.3/lib/active_record/dynamic_matchers.rb:22:in `method_missing'
/usr/local/bundle/gems/attr_encrypted-3.0.3/lib/attr_encrypted.rb:295:in `method_missing'
/usr/local/bundle/gems/attr_encrypted-3.0.3/lib/attr_encrypted/adapters/active_record.rb:129:in `method_missing_with_attr_encrypted'
/mastodon/app/models/web/push_subscription.rb:53:in `push_payload'
```

* Specify serializer in Web::NotificationSerializer
2017-09-02 16:50:42 +02:00
Surinna Curtis
8cc1ed3c55 Don't unconditionally call preventDefault and stopPropagation on all keyup events (#4777)
* UploadArea should only preventDefault for Escape

This will make accessibility for some things less effortful, since we won't have to define a prior event handler to do whatever should be happening by default.

* Remove workaround for fixed bug in SettingToggle

SettingToggle was toggling itself in response to keydown of space, and then the keyup was doing it again
2017-09-02 16:27:16 +02:00
m4sk1n
5e1e466da0 i18n: Update Polish translation (#4775)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-09-02 16:25:54 +02:00
Lynx Kotoura
cfe39fb58d Adjust settings pages (#4765)
* views: Adjust heading positions

* Adjust settings pages

Adjust label. Adjust tables. Adjust admin/reports/* pages. Fix 2FA QR code style for narrow devices. Widen several pages. Increase contrast.

* Remove trailing whitespace
2017-09-02 15:49:28 +02:00
Yamagishi Kazutoshi
a0294c8880 Add Japanese translate for #4561 (#4771) 2017-09-02 14:02:29 +02:00
Yamagishi Kazutoshi
ba8fb2fd0f Add text color style for noscript link (#4772) 2017-09-02 14:02:15 +02:00
Yamagishi Kazutoshi
6fd2e8c3c5 Fix profile page when use system's font (#4774) 2017-09-02 14:01:59 +02:00
Yamagishi Kazutoshi
15963a15c6 Disable embed modal when private status (#4773)
* Disable embed modal when private status

* Remove `reblogDisabled`
2017-09-02 14:01:44 +02:00
Eugen Rochko
1b5806b744 Define missing JSON-LD properties (#4767)
Using _: property names is discouraged, as in the future,
canonicalization may throw an error when encountering that instead
of discarding it silently like it does now.

We are defining some ActivityStreams properties which we expect
to land in ActivityStreams eventually, to ensure that future versions
of Mastodon will remain compatible with this even once that happens.
Those would be `locked`, `sensitive` and `Hashtag`

We are defining a custom context inline for some properties which we
do not expect to land in any other context. `atomUri`, `inReplyToAtomUri`
and `conversation` are part of the custom defined OStatus context.
2017-09-02 14:01:23 +02:00
Eugen Rochko
1b1e025b41 Use updated ActivityStreams context (added: sharedInbox) (#4764) 2017-09-02 14:00:58 +02:00
mayaeh
ab9f1b6e50 Add japanese translations for embed modal feature. (#4770) 2017-09-02 14:48:51 +09:00
Yamagishi Kazutoshi
b767eb7ff8 Add RoutingHelper (#4769) 2017-09-02 03:03:20 +02:00
m4sk1n
0b32338e3f Add link to 'noscript' message (#4561)
* Add link to 'noscript' message

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

* remove indent
2017-09-02 01:52:28 +02:00
Eugen Rochko
e482595a5d Add ActivityPub handler for Delete->Actor activities (#4761) 2017-09-01 21:54:42 +02:00
Eugen Rochko
9c04fadec9 Finish up embed modal feature (#4759)
* Add embed button to dropdowns of in-timeline statuses

* yarn run manage:translations
2017-09-01 21:30:13 +02:00
Eugen Rochko
390bfec6da Avoid sending some ActivityPub payloads if the receiver will get them through distribution (#4739) 2017-09-01 21:26:01 +02:00
Eugen Rochko
c2980d5b17 Do not rely on activity arriving exactly once after delete arrived (#4754) 2017-09-01 21:12:59 +02:00
Cygnan
a75aa62f5b Adjust padding on the public profile page (#4757)
* Fix a style issue on the public profile page for some mobile browsers

Signed-off-by: Cygnan <email@cygnan.com>

* Set padding-bottom to 20px

Signed-off-by: Cygnan <email@cygnan.com>
2017-09-01 21:01:23 +02:00
unarist
8fd8f81ae7 Deduplicate with local status on Create activity (#4763) 2017-09-01 21:00:43 +02:00
Eugen Rochko
921cf3e9c8 Fix NoMethodError (#4762) 2017-09-01 20:50:42 +02:00
Eugen Rochko
7dc5035031 Make PreviewCard records reuseable between statuses (#4642)
* Make PreviewCard records reuseable between statuses

**Warning!** Migration truncates preview_cards tablec

* Allow a wider thumbnail for link preview, display it in horizontal layout (#4648)

* Delete preview cards files before truncating

* Rename old table instead of truncating it

* Add mastodon:maintenance:remove_deprecated_preview_cards

* Ignore deprecated_preview_cards in schema definition

* Fix null behaviour
2017-09-01 16:20:16 +02:00
Yamagishi Kazutoshi
2305f7c391 Use system's default font on non web UI pages (#4553)
* Use system's default font on non web UI pages

* Remove import for Redirect
2017-09-01 16:13:31 +02:00
Eugen Rochko
ff7d02b236 Make first use less overwhelming with browser permissions (#4760)
- Ask for desktop notifications after 1 minute of use instead of
  instantly
- Ask for protocol handler permission after 5 minutes of use
  instead of instantly
2017-09-01 16:07:08 +02:00
Damien Erambert
1a0df58878 Update FR locales (#4714)
* Make the fr locales up-to-date with the last changes (new profile view, applications)

* Use the same wording for toots in fr.yml and fr.json

* Translate the pin related strings

* Translate pin-related locales on the front-end

* Add missing locales in doorkeeper.fr.yml and remove un-used ones

* Change "posts" back to "status" in the /about/more page in fr.yml

* Fix typos for "status" in fr.yml

* fix typo for "status" in fr.json

* Remove duplicate string

* Non-breaking space before punctuation

* 'Better' translation for "unpin"

* Put back 'pouet' where it was already

* Fix

* Fix
2017-09-01 14:09:01 +02:00
Eugen Rochko
74437c6bff Refactor Web::PushSubscription, remove welcome message (#4524)
* Refactor Web::PushSubscription, remove welcome message

* Add missing helper

* Use locale of the receiver on push notifications (#4519)

* Remove unused translations

* Fix dir on notifications
2017-09-01 13:35:23 +02:00
unarist
504737e860 Convert OStatus tag to ActivityPub id on in_reply_to resolution (#4756) 2017-09-01 13:34:04 +02:00
unarist
af2d22f88c Fallback from perform_via_activitypub on private posts (#4758)
Currently, private / direct posts via OStatus from AP compatible instance will be dropped due to failing to fetch AP version.

So this fallbacks to OStatus handling:

* when failed to fetch ActivityPub version
* when status is neither :public nor :unlisted
2017-09-01 12:33:02 +02:00
Eugen Rochko
9a5ae09620 Remove identity context from output of LinkedDataSignature (#4753) 2017-08-31 21:32:09 +02:00
unarist
f7937d903c Don't process ActivityPub payload if signature is invalid (#4752)
* Don't process ActivityPub payload if signature is invalid

* Fix style issue
2017-08-31 17:18:49 +02:00
James
6b2be5dbfb Guarantee Subscription service first account has proper URL details (#4732)
* Guarantee Subscription service first account has proper URL details

Subscription Service potentially could break if the first user suspended
themselves, creating a situation where the urls that populate throughout
subscription service's PuSH request would cause the remote API to throw 503 errors.

Guaranteeing that the first account picked is not suspended prevents this problem.

* Fix style issue
2017-08-31 15:44:00 +02:00
Akihiko Odaki
69957ed10a Fix the usages of Detect Passive Events (#4749) 2017-08-31 11:20:54 +02:00
Eugen Rochko
d1a78eba15 Embed modal (#4748)
* Embed modal

* Proxy OEmbed requests from web UI
2017-08-31 03:38:35 +02:00
Eugen Rochko
2db9ccaf3e Add sharedInbox to actors (#4737) 2017-08-31 00:02:59 +02:00
Wonderfall
cecf204bbb Update to Alpine 3.6 (#4747) 2017-08-30 23:52:19 +02:00
MitarashiDango
fec13735a7 error fixed (when loading pages in single column mode.) (#4746) 2017-08-30 17:30:25 +02:00
Eugen Rochko
7b8f262840 Forward ActivityPub creates that reply to local statuses (#4709)
* Forward ActivityPub creates that reply to local statuses

* Fix test

* Fix wrong signers
2017-08-30 15:37:02 +02:00
Yamagishi Kazutoshi
3f51a22d3b Add close tag of iframe for oEmbed response (#4745)
* Add close tag of iframe for oEmbed response

* add comma
2017-08-30 14:03:17 +02:00
nullkal
39e7a763ff Use request.remote_ip instead of request.ip (#4744) 2017-08-30 10:24:30 +02:00
Eugen Rochko
e95bdec7c5 Update status embeds (#4742)
- Use statuses controller for embeds instead of stream entries controller
- Prefer /@:username/:id/embed URL for embeds
- Use /@:username as author_url in OEmbed
- Add follow link to embeds which opens web intent in new window
- Use redis cache in development
- Cache entire embed
2017-08-30 10:23:43 +02:00
Eugen Rochko
fcca31350d Remove unneccesary indices (#4738)
We only look up status_pins by account_id, or account_id and status_id,
never by status_id
2017-08-30 05:04:20 +02:00
Eugen Rochko
ee72a39641 Update bundler-audit and brakeman (#4740) 2017-08-30 03:30:13 +02:00
abcang
f59ed3a4fa Scroll smoothly to the right (#4735) 2017-08-29 17:06:19 +02:00
MitarashiDango
7be620775e fix error when single columns mode. (#4734) 2017-08-29 16:11:28 +02:00
Eugen Rochko
4c76402ba1 Serialize ActivityPub alternate link into OStatus deletes, handle it (#4730)
Requires moving Atom rendering from DistributionWorker (where
`stream_entry.status` is already nil) to inline (where
`stream_entry.status.destroyed?` is true) and distributing that.

Unfortunately, such XML renderings can no longer be easily chained
together into one payload of n items.
2017-08-29 16:11:05 +02:00
Akihiko Odaki
9958eba356 Do not scroll the columns area due to redirection (#4541)
Commit 9d1f8b9d6a scrolls the columns area
when the route changes since the user is likely to want to see the
rightmost column in such cases.

However, redirection is automatic and does not indicate users' intension.
Do not scroll the columns area due to one.
2017-08-29 14:16:21 +02:00
abcang
0827c09c44 Generalized the infinite scrollable list (#4697) 2017-08-28 22:23:44 +02:00
unarist
938cd2875b Fix Delete activity handling when the status has been reblogged (#4729) 2017-08-28 22:08:11 +02:00
unarist
7876aed134 Fix deletion of status which has been reblogged (#4728) 2017-08-28 21:38:59 +02:00
abcang
ce9a5f358e rescue HTTP::ConnectionError in RemoteFollowController#create (#4726) 2017-08-28 19:12:09 +02:00
Lynx Kotoura
8f527bd588 Add japanese translations for shorten display of large numbers (#4722) 2017-08-28 08:16:49 +09:00
Lynx Kotoura
07994eed00 Adjust "signed in as" pages (#4720)
* Adjust "signed in as" pages


Fix min-width


Set width of .account-header .name

To apply text-overflow and overflow settings
Set overflow for detailed-status__display-name

* Remove trailing whitespace
2017-08-28 00:01:07 +02:00
Lynx Kotoura
bab9afaa09 Adjust public profile pages (#4713)
* Adjust account-grid in public profiles

Full-width card on mobile UI. Set break-word for long name and ID. Fix margin.

* Reduce padding-bottom of public profiles

* Revive next prev buttons in mobile public profiles

In followers followees pages.

* Revert break-word for username

* Fix overflow of display_name

Need re-setting text-overflow and overflow in display: block;
2017-08-27 23:59:51 +02:00
Eugen Rochko
15093f9113 Shorten display of large numbers on public profiles (#4711) 2017-08-27 17:04:45 +02:00
mayaeh
f92d991e52 Add japanese translations for Pinned statuses based on pawoo. (#4717)
Add japanese translations for pin_errors.
2017-08-28 00:03:27 +09:00
Eugen Rochko
26402ee2cb Adjust RTL styles (#4712) 2017-08-27 13:35:18 +02:00
unarist
f095a9f8a5 Allow Symbol keyed Hash in LinkedDataSignature (#4715)
SerializarbleResource#as_json serializes to Symbol keyed Hash, but current
implementation of LinkedDataSignature expects String keyed Hash.

So it generates broken payload.
2017-08-27 13:35:01 +02:00
Eugen Rochko
0d5d11eeff Add _:inReplyToAtomUri to ActivityPub (#4702) 2017-08-26 19:55:10 +02:00
Eugen Rochko
0397c58b61 Forward ActivityPub deletes to followers of rebloggers (#4706) 2017-08-26 18:52:53 +02:00
Eugen Rochko
884b085f53 Use Tombstone and _:atomUri in Delete activities as fallback (#4704) 2017-08-26 16:10:35 +02:00
Eugen Rochko
2a2698e450 Add ActivityPub serializer for Undo of Announce (#4703) 2017-08-26 15:32:40 +02:00
Lynx Kotoura
8ecfdd8795 Set margin between character-counter and compose-form__buttons (#4698)
For some languages publish translation is long.
2017-08-26 14:23:20 +02:00
Eugen Rochko
00840f4f2e Add handling of Linked Data Signatures in payloads (#4687)
* Add handling of Linked Data Signatures in payloads

* Add a way to sign JSON, fix canonicalization of signature options

* Fix signatureValue encoding, send out signed JSON when distributing

* Add missing security context
2017-08-26 13:47:38 +02:00
Anna e só
1cebfed23e Added new translations of error messages, block and mute domains and users, privacy disclaimers, etc (#4700)
* Added new translations of error messages, block and mute domains and users

* Added new translations of error messages, block and mute domains and users
2017-08-26 20:45:35 +09:00
masarakki
649a20ab46 authorize-follow-requests-after-unlocking (#4658) 2017-08-26 12:40:03 +02:00
Yamagishi Kazutoshi
3ac7b353f8 Fix missing at-sign (regression from #4688) (#4705) 2017-08-26 12:39:26 +02:00
Lynx Kotoura
21bb4a6c3b Fix ar.json (#4699)
Remove ! from compose_form.publish
2017-08-25 20:02:44 +02:00
nullkal
c2af138113 Allow multiple pinned statuses to be shown and make them be ordered b… (#4690)
* Allow multiple pinned statuses to be shown and make them be ordered by pinned date

* Set timestamps NOT NULL

* Make single-line pinned_statuses

* Spec for pinned_statuses

* Remove redundant empty line
2017-08-25 18:50:52 +02:00
unarist
fb8aa2b3ba Apply user timezone for the title attribute of .time-ago (#4693) 2017-08-25 17:21:16 +02:00
Yamagishi Kazutoshi
00f9f16f94 Change timezone of the datetime to what browser specifies (#4688) 2017-08-25 17:21:00 +02:00
Lynx Kotoura
18f69fb964 Adjust styles of landing pages. (#4682)
* Adjust about.scss

* Delete trailing whitespace.
2017-08-25 17:19:35 +02:00
Quent-in
04c3fb2189 i18n Updated strings (#4675 - pinned toot) (#4695)
* Added string for pinned toots

* Pinned toot #4675 + missing string

Somehow I deleted it "enabled_success"

* update after advice
2017-08-25 23:04:52 +09:00
Yamagishi Kazutoshi
7c03e59338 Update addressable to version 2.5.2 (#4686) 2017-08-25 14:17:08 +02:00
Yamagishi Kazutoshi
b88635202f Add label for application scopes (#4691)
* Add label for application scopes

* hint
2017-08-25 13:03:26 +02:00
m4sk1n
409051c22c i18n: Update Polish translation #4675 (#4692)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-25 17:58:31 +09:00
Eugen Rochko
9caa90025f Pinned statuses (#4675)
* Pinned statuses

* yarn manage:translations
2017-08-25 01:41:18 +02:00
Ratmir Karabut
c5157ef07b Update Russian translation (#4685)
* Add Russian translation (ru)

* Fix a missing comma

* Fix the wording for better consistency

* Update Russian translation

* Arrange Russian setting alphabetically

* Fix syntax error

* Update Russian translation

* Fix formatting error

* Update Russian translation

* Update Russian translation

* Update ru.jsx

* Fix syntax error

* Remove two_factor_auth.warning (appears obsolete)

* Add missing strings in ru.yml

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

* Fix translation consistency

* Update Russian translation

* Update Russian translation (pluralizations)

* Update Russian translation

* Update Russian translation

* Update Russian translation (pin)

* Update Russian translation (account deletion)

* Fix extra line

* Update Russian translation (sessions)

* Update Russian translation

* Update Russian translation

* Fix merge conflicts (revert)
2017-08-25 07:11:06 +09:00
Damien Erambert
f72ed21cd6 Don't load Roboto webfont when system font is used in the app (#4591)
* Don't load Roboto webfont when system font is used in the app

* remove trailing whitespace
2017-08-24 19:28:49 +02:00
Yamagishi Kazutoshi
da172a8b1b Disable babel-loader cache when development environment (#4684) 2017-08-24 19:27:52 +02:00
Eugen Rochko
cf615abbf9 Add configuration to disable private status federation over PuSH (#4582) 2017-08-24 17:51:32 +02:00
unarist
b01a19fe39 Fetch reblogs as Announce activity instead of Note object (#4672)
* Process Create / Announce activity in FetchRemoteStatusService

* Use activity URL in ActivityPub for reblogs

* Redirect to the original status on StatusesController#show
2017-08-24 16:21:42 +02:00
Eugen Rochko
c66fe2aeba Minor performance improvement for test suite (#4678) 2017-08-24 13:31:55 +02:00
Yamagishi Kazutoshi
fbe1115114 Remove eslint-disable comments (#4681)
Do not reject console.error and console.warn with ESLint rules.
2017-08-24 12:15:36 +02:00
Quent-in
e4c761f902 l18n update OC new strings (#4664) (#4680)
* New strings

* Update

Thin non breaking spaces

* Update

Thin non breaking spaces

* Update

Thin non breaking spaces
2017-08-24 16:16:32 +09:00
HIKARU KOBORI
2c6a85832c Fix typo in admin/status_controller.rb (#4679) 2017-08-24 04:03:52 +02:00
m4sk1n
829e2e8c5d Update Polish translation (#4674)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-23 17:45:29 +02:00
m4sk1n
8a716c9e96 Introduce CODEOWNERS file (#4670)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-23 15:21:00 +02:00
nullkal
80393a23d0 Use checkboxes for application scope setting (#4671) 2017-08-23 15:16:20 +02:00
Yamagishi Kazutoshi
8d23667536 Add Japanese translations for #2758, #4506, #4521, #4600 and #4664 (#4665)
* Add Japanese translations for #2758, #4506, #4521, #4600 and #4664

* Do not translate Inbox URL and Outbox URL

* Remove "あなたの"

* Remove "あなたの"
2017-08-23 14:14:22 +02:00
unarist
9846806cb5 Fix Japanese translation (#4669) 2017-08-23 20:07:29 +09:00
unarist
760cfe328f Fix accessing to XML attribute in FetchAtomService (#4668) 2017-08-23 12:25:57 +02:00
Eugen Rochko
c1b086a538 Fix up the applications area (#4664)
- Section it into "Development" area
- Improve UI of application form, index, and details
2017-08-23 00:59:35 +02:00
Daigo 3 Dango
696c2c6f2f Add Mastodon::Source.url (#4643)
* Add Mastodon::Source.url

* Update spec

* Refactor

Move things frmo Mastodon::Source to Mastodon::Version
2017-08-22 22:54:19 +02:00
unarist
5927b43c0f Ignore empty response in ActivityPub::FetchRemoteStatusService (#4661)
* Ignore empty response in ActivityPub::FetchRemoteStatusService

This fixes `NoMethodError: undefined method `[]' for nil:NilClass` error.

* Check json.nil? in JsonLdHelper#supported_context?
2017-08-22 20:00:49 +02:00
Colin Mitchell
871c0d251a Application prefs section (#2758)
* Add code for creating/managing apps to settings section

* Add specs for app changes

* Fix controller spec

* Fix view file I pasted over by mistake

* Add locale strings. Add 'my apps' to nav

* Add Client ID/Secret to App page. Add some visual separation

* Fix rubocop warnings

* Fix embarrassing typo

I lost an `end` statement while fixing a merge conflict.

* Add code for creating/managing apps to settings section

- Add specs for app changes
- Add locale strings. Add 'my apps' to nav
- Add Client ID/Secret to App page. Add some visual separation
- Fix some bugs/warnings

* Update to match code standards

* Trigger notification

* Add warning about not sharing API secrets

* Tweak spec a bit

* Cleanup fixture creation by using let!

* Remove unused key

* Add foreign key for application<->user
2017-08-22 18:33:57 +02:00
Yamagishi Kazutoshi
11a7507318 Add delete account link for French (#4659) 2017-08-22 18:31:42 +02:00
unarist
d63de55ef8 Fix bugs which OStatus accounts may detected as ActivityPub ready (#4662)
* Fallback to OStatus in FetchAtomService

* Skip activity+json link if that activity is Person without inbox
* If unsupported activity was detected and all other URLs failed, retry with ActivityPub-less Accept header

* Allow mention to OStatus account in ActivityPub

* Don't update profile with inbox-less Person object
2017-08-22 18:30:15 +02:00
Eugen Rochko
72bb3e03fd Support more variations of ActivityPub keyId in signature (#4630)
- Tries to avoid performing HTTP request if the keyId is an actor URI
- Likewise if the URI is a fragment URI on top of actor URI
- Resolves public key, returns owner if the owner links back to the key
2017-08-21 22:57:34 +02:00
Eugen Rochko
f391a4673a Periodically remove expired PuSH subscribers (#4654) 2017-08-21 22:56:33 +02:00
Lynx Kotoura
143b77e10d Increase contrast in landing pages (#4567)
* Increase contrast in about and about/more page

* Lighten em color in landing pages

* Increase contrast in landing pages


Fix about.scss
2017-08-21 21:59:03 +02:00
Eugen Rochko
4cbb638604 Fix visual line-break glitch with .invisible parts of links (#4655) 2017-08-21 17:59:34 +02:00
Eugen Rochko
3534e115e5 Do not try to re-subscribe to unsubscribed accounts (#4653) 2017-08-21 17:32:41 +02:00
abcang
ea958cae7f Refactoring streaming connections (#4645) 2017-08-21 15:04:34 +02:00
Yamagishi Kazutoshi
10e9a9a3f9 Use URI.join even when S3 enabled (#4652) 2017-08-21 12:42:16 +02:00
Eugen Rochko
6e9eda5331 ActivityPub migration procedure (#4617)
* ActivityPub migration procedure

Once one account is detected as going from OStatus to ActivityPub,
invalidate WebFinger cache for other accounts from the same domain

* Unsubscribe from PuSH updates once we receive an ActivityPub payload

* Re-subscribe to PuSH unless already unsubscribed, regardless of protocol
2017-08-21 01:14:40 +02:00
m4sk1n
4c23544714 i18n: Minor changes in Polish translation (#4649)
* i18n: Minor changes in Polish translation

* i18n: pl
2017-08-21 07:57:28 +09:00
Eugen Rochko
74e5078795 Fix #4637 - Re-add missing doorkeeper_authorize for /api/v1/verify_credentials (#4650) 2017-08-21 00:41:08 +02:00
Yamagishi Kazutoshi
110227ac5e Remove status from favorites list when unfavorited (#4597) 2017-08-20 23:23:05 +02:00
unarist
f26758dc01 Fix .information-board style for Safari (#4602)
flex-basis: 0 allows make flexbox smaller than its contents on Safari <10.

https://github.com/philipwalton/flexbugs#1-minimum-content-sizing-of-flex-items-not-honored
2017-08-20 20:45:44 +02:00
abcang
23792f5a7c Fix hasUnread on HashtagTimeline (#4644) 2017-08-20 17:12:06 +02:00
Eugen Rochko
fe5b66aa08 Handle duplicate ActivityPub activities (#4639)
* Handle duplicate ActivityPub activities

Only perform side-effects when record processed for the first time

* Fast-forward repeat follow requests
2017-08-20 16:53:47 +02:00
Quent-in
93d4192a67 l10n update OC : Redesign public profiles (#4608) (#4646)
New strings added to be shown on the new profile page
2017-08-20 21:49:12 +09:00
takayamaki
d5acf4275f Improve about ja translation standalone.public_title (#4641) 2017-08-20 20:27:14 +09:00
Eugen Rochko
412ea87306 Improve ActivityPub/OStatus compatibility (#4632)
*Note: OStatus URIs are invalid for ActivityPub. But we have them for
as long as we want to keep old OStatus-sourced content and as long as
we remain OStatus-compatible.*

- In Announce handling, if object URI is not a URL, fallback to object URL
- Do not use specialized ThreadResolveWorker, rely on generalized handling
- When serializing notes, if parent's URI is not a URL, use parent's URL
2017-08-19 18:44:48 +02:00
Eugen Rochko
774b8661bc Revert #4616 (#4638) 2017-08-19 02:23:47 +02:00
Eugen Rochko
c7d2619ab1 Parse OStatus tag URIs in ActivityPub handlers when those are local (#4631) 2017-08-18 11:24:44 +02:00
Yamagishi Kazutoshi
2edfdab6e6 Don't send Link header when don't know prev and next links (#4633) 2017-08-18 10:42:59 +02:00
Eugen Rochko
4edf9d849f Make ActivityPub::TagManager#local_uri? recognize local URIs with ports (#4628) 2017-08-18 03:21:59 +02:00
Eugen Rochko
10489b4e4a If url attribute not present in Note, fallback to id attribute (#4629) 2017-08-18 02:29:12 +02:00
Eugen Rochko
40c45f5dd9 Put ActivityPub alternate link into Atom, prefer it when processing Atom (#4623) 2017-08-18 01:03:18 +02:00
nightpool
efec02f153 use existing inflections instead of custom helper (#4624)
* use existing inflections instead of custom helper

* use ActiveSupport versions
2017-08-17 23:20:50 +02:00
Eugen Rochko
116b8a6363 Fix #4607 - Accept/reject activities use FollowRequest, which has inverse relations (#4616) 2017-08-17 22:15:37 +02:00
Eugen Rochko
ad892dbc0c Add _:atomUri property for deduplicating OStatus/ActivityPub legacy records (#4593) 2017-08-17 21:35:00 +02:00
nullkal
075d6a1e13 Show what protocol is used for accounts in admin/accounts#index (#4622)
* Show what protocol used for in admin/accounts#index

* Add frozen_string_literal
2017-08-17 17:52:40 +02:00
nullkal
54a04e3658 Update charlock_holmes to 0.7.5 (#4620) 2017-08-17 14:46:53 +02:00
Naoki Kosaka
462c30e26c Update Japanese Translation. (Redesign public profiles) (#4612) 2017-08-17 06:19:37 +09:00
m4sk1n
2a04bdc87a i18n: Update Polish translation (#4613)
* i18n: Update Polish translation

* Update pl.json
2017-08-16 22:14:23 +02:00
Eugen Rochko
ca7ea1aba9 Redesign public profiles (#4608)
* Redesign public profiles

* Responsive design

* Change public profile status filtering defaults and add options

- No longer displays private/direct toots even if you are permitted access
- By default omits replies
- "With replies" option
- "Media only" option

* Redesign account grid cards

* Fix style issues
2017-08-16 17:12:58 +02:00
Clworld
f814661fca Make share intent modal to make "signed in as" shown. (#4611)
* Make share intent modal to make "signed in as" shown.

* fix glitch on mobile.
2017-08-16 16:48:44 +02:00
Quent-in
e33c28a6d8 Update ActivityPub (#4600) (#4609)
Update: new string + more translations for the time in words
2017-08-16 17:21:34 +09:00
abcang
e120d09c98 Fix require_user! behavior when not logged in (#4604) 2017-08-15 14:14:12 +02:00
Eugen Rochko
4fcbb1f838 Re-add missing transaction around status-from-OStatus creation (#4603) 2017-08-14 21:37:21 +02:00
unarist
a855956185 Fix ActivityPub follow interaction and add more specs (#4601) 2017-08-14 16:57:46 +02:00
unarist
5b9ae7981e Update /admin/accounts/:id view for ActivityPub (#4600)
* Add protocol field
* Switch protocol specific information according to active protocol
* Hide PuSH subscription related buttons if ActivityPub is active
2017-08-14 14:09:00 +02:00
Yamagishi Kazutoshi
5f22c0189d Add support for searching AP users (#4599)
* Add support for searching AP users

* use JsonLdHelper
2017-08-14 14:08:34 +02:00
Eugen Rochko
26d26644ac Require "inbox" to be set on actor to be ActivityPub-ready (#4595) 2017-08-14 11:27:25 +02:00
Eugen Rochko
3c6503038e Add protocol handler. Handle follow intents (#4511)
* Add protocol handler. Handle follow intents

* Add share intent

* Improve code in intents controller

* Adjust share form CSS
2017-08-14 04:53:31 +02:00
Yamagishi Kazutoshi
96e9ed13de Fix search (regression from #4589) (#4594) 2017-08-14 04:50:56 +02:00
Eugen Rochko
6df8bd277b Set correct content-type for ActivityPub JSON (#4592) 2017-08-14 04:16:43 +02:00
Eugen Rochko
4e75f0d889 Hook up URL-based resource look-up to ActivityPub (#4589) 2017-08-14 02:29:36 +02:00
Eugen Rochko
a2aeacbfee Add alternate links to ActivityPub resources from HTML/HEAD variants (#4586) 2017-08-13 00:45:04 +02:00
Eugen Rochko
b7370ac8ba ActivityPub delivery (#4566)
* Deliver ActivityPub Like

* Deliver ActivityPub Undo-Like

* Deliver ActivityPub Create/Announce activities

* Deliver ActivityPub creates from mentions

* Deliver ActivityPub Block/Undo-Block

* Deliver ActivityPub Accept/Reject-Follow

* Deliver ActivityPub Undo-Follow

* Deliver ActivityPub Follow

* Deliver ActivityPub Delete activities

Incidentally fix #889

* Adjust BatchedRemoveStatusService for ActivityPub

* Add tests for ActivityPub workers

* Add tests for FollowService

* Add tests for FavouriteService, UnfollowService and PostStatusService

* Add tests for ReblogService, BlockService, UnblockService, ProcessMentionsService

* Add tests for AuthorizeFollowService, RejectFollowService, RemoveStatusService

* Add tests for BatchedRemoveStatusService

* Deliver updates to a local account to ActivityPub followers

* Minor adjustments
2017-08-13 00:44:41 +02:00
Eugen Rochko
ccdd5a9576 Add serializing/unserializing of "locked" actor attribute (#4585) 2017-08-12 17:41:03 +02:00
Eugen Rochko
40be4ea239 Extend Devise remember_me longevity to 1 year instead of 2 weeks (#4587)
Force SSL only cookies for remember_me, adjust confirmation
expiration time to fit with the user cleanup scheduler
2017-08-12 16:30:59 +02:00
Eugen Rochko
3d47154c20 Only PuSH-resubscribe to OStatus accounts (#4583) 2017-08-12 02:54:54 +02:00
Sylvhem
d0a217eb92 Minor fixes in the French translation (#4580)
* Ajout de traductions manquantes

Ajoute des traductions pour les chaînes n’en ayant pas en version 1.5.1.

Add translations for the strings that are missing them in 1.5.1.

* 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 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.

* 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 « status » par « statut »

Remplace le mot anglais « status » par sa traduction française « statut ».

Replace the English word "status" by its French translation "statut".

* Correction de la politique de confidentialité

Apporte diverses corrections à la traduction de la politique de confidentialité.

Add various fixes to the privacy policy's translation.

* Remplace « mentionné » par « mentionné·e »

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

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

* Remplace « Coup d’œil » par « Jeter un coup d’œil… »

Remplace la première traduction par une forme plus proche de la version originelle.

Replace the first translation by something closer to the original version.

* Remplace « Bon Appétoot ! » par « Bon appouetit ! »

Remplace « Bon Appétoot ! » par « Bon appouetit ! » pour essayer de conserver le jeu de mot.

Replace « Bon Appétoot ! » by « Bon appouetit ! » to keep the pun.

* Remplace « Bon Appétoot ! » par « Bon appouetit ! » (2)

Remplace « Bon Appétoot ! » par « Bon appouetit ! » pour essayer de conserver le jeu de mot.

Replace « Bon Appétoot ! » by « Bon appouetit ! » to keep the pun.f

* Corrections

Corrige des fautes d’orthographe et change « appouetit » pour « appouétit ».

Correct some mistakes and change "appouetit" to "appouétit".
2017-08-12 01:33:30 +02:00
Eugen Rochko
81c1303cd6 Handle ActivityPub follows correctly (#4571)
* Handle ActivityPub follows correctly

ActivityPub follows are follow-requests. Always require an Accept.
If account is not locked, auto-accept.

* Handle ActivityPub Accept/Reject-Follow

* Fix wrong method

* Fix wrong class
2017-08-10 22:33:12 +02:00
Quent-in
4b8e4dca26 l10n Update OC #4521 (#4577)
* l10n Update OC #4521

Link => token
provider => provesidor
+ more generalized way of using present participle

* Update oc.yml
2017-08-10 22:15:26 +02:00
spla
10cdad3e7d Added new catalan strings (#4574)
* Add Catalan language

* Add Catalan language

* Update ca.json

* Update ca.json

* Update ca.json

* Update ca.json

* Update ca.json

* Update ca.json

* Update settings_helper.rb

* Update mastodon.js

* Update index.js

* Update application.rb

* Update ca.yml

* removed extra spaces at line 225

* Catalan translation update

added activerecord.ca.yml

* Update activerecord.ca.yml

Done

* Updated activerecord.ca.yml

* Catalan language updated

* Catalan language updated

* Catalan language updated

* Catalan language updated

* Catalan language updated

* Update ca.json

Removed :

<<<<<<< HEAD
  "getting_started.support": "{faq} • {userguide} • {apps}",
=======
>>>>>>> upstream/master

* Syncing to master

* Added new Catalan strings

* removed config.secret_key line

* Corrected <sotrong> tag to <strong>

Line 515

* Removed extra line

* Reverted

* yarn.lock reverted
2017-08-10 21:52:40 +09:00
Yamagishi Kazutoshi
d9a1fb134a Fix emoji picker scrollbar style (#4572) 2017-08-10 13:41:12 +02:00
Eugen Rochko
fdea173237 Add Digest header to requests with body, handle acct and URI keyId (#4565) 2017-08-09 23:54:14 +02:00
m4sk1n
4e1bf082ce i18n: Improve admin panel translation (pl) (#4559)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-09 07:46:21 +09:00
Ondřej Hruška
b1c8a702a4 Add favourited toot to favourites column (#4562)
* Add faved toot to faves column

* renamed append to prepend for clarity
2017-08-09 00:22:26 +02:00
Ondřej Hruška
820099813f add scrollTop to ui/components/column (#4563) 2017-08-09 00:21:58 +02:00
Gergely Nagy
2ebe4ff568 api/instances: Include the stats from the /about/more page (#4074)
To be able to pull some basic statistics from a Mastodon instance, include the
user, status and connected domain counters in the `/api/v1/instance` response.

Fixes #3570.

Signed-off-by: Gergely Nagy <algernon@madhouse-project.org>
2017-08-08 22:18:12 +02:00
Ondřej Hruška
61bfce5aa9 add missing @ to the onboarding modal (#4560) 2017-08-08 22:13:04 +02:00
Eugen Rochko
dd7ef0dc41 Add ActivityPub inbox (#4216)
* Add ActivityPub inbox

* Handle ActivityPub deletes

* Handle ActivityPub creates

* Handle ActivityPub announces

* Stubs for handling all activities that need to be handled

* Add ActivityPub actor resolving

* Handle conversation URI passing in ActivityPub

* Handle content language in ActivityPub

* Send accept header when fetching actor, handle JSON parse errors

* Test for ActivityPub::FetchRemoteAccountService

* Handle public key and icon/image when embedded/as array/as resolvable URI

* Implement ActivityPub::FetchRemoteStatusService

* Add stubs for more interactions

* Undo activities implemented

* Handle out of order activities

* Hook up ActivityPub to ResolveRemoteAccountService, handle
Update Account activities

* Add fragment IDs to all transient activity serializers

* Add tests and fixes

* Add stubs for missing tests

* Add more tests

* Add more tests
2017-08-08 21:52:15 +02:00
Lynx Kotoura
dcbc1af38a Fix short description in about/more page (#4554) 2017-08-08 15:49:32 +02:00
Yamagishi Kazutoshi
81c41d8681 Add coalesce option to avatar and header convert processor (#4552)
Resolve #3199
2017-08-08 15:49:14 +02:00
雨宮美羽
ec3be87a2b improve zh-CN translations (#4557) 2017-08-08 15:48:19 +02:00
unarist
b42c018bb8 Add Content-Type header on throttled response to fix mojibake (#4558)
application/json only allows Unicode, so this prevents from wrong charset detection.
2017-08-08 15:47:35 +02:00
TheInventrix
c9fd6f386c unify short description styling (#4548)
apply same style class to the About description on both the landing page
and the about/more page
2017-08-08 01:50:15 +02:00
Yamagishi Kazutoshi
1b5d26735e Revert "Set false to animated options for thumbnail processor" (#4550)
* Revert "Adjust tags and accounts page (#4534)"

This reverts commit a3e53bd442.

* Revert "feat: Cache status height to avoid expensive renders (#4439)"

This reverts commit 8eb6d171e6.

* Revert "Refactor Avatar and AvatarOverlay to have 'account' as prop instead of src and staticSrc (#4526)"

This reverts commit 5942347407.

* Revert "Update dependencies for Ruby (#4543)"

This reverts commit 22db947225.

* Revert "[Docker] Add multicore support to "make" and "bundler" (#4544)"

This reverts commit 5d408fd9aa.

* Revert "It makes no sense to try using invalid or expired link again (#4521)"

This reverts commit 47579ec58c.

* Revert "i18n: Update Polish translation (#4545)"

This reverts commit 3363a05539.

* Revert "Set false to animated options for thumbnail processor (#4547)"

This reverts commit 87f10d476c.
2017-08-08 01:49:56 +02:00
Lynx Kotoura
a3e53bd442 Adjust tags and accounts page (#4534)
* Adjust tag and accounts page

* Remove units from 0px paddings
2017-08-07 20:33:06 +02:00
Sorin Davidoi
8eb6d171e6 feat: Cache status height to avoid expensive renders (#4439)
* feat: Cache status height to avoid expensive renders

* feat: Escape content and emojify in reducers

* fix(css): Remove backface-visibility: hidden from .scrollable

* fix(statuses): Avoid creating DOMParses inside a loop
2017-08-07 20:32:03 +02:00
Ondřej Hruška
5942347407 Refactor Avatar and AvatarOverlay to have 'account' as prop instead of src and staticSrc (#4526)
* Refactored Avatar and AvatarOverlay (DRY) to have 'account' as prop.
Also removed animate attribute from compose navigation bar, which should
have never been there. Added test for avatar overlay.

* fix broken tests

* god dammit another bug in tests! travis please let this pass

* formatting in avatar overlay
2017-08-07 19:44:55 +02:00
Yamagishi Kazutoshi
22db947225 Update dependencies for Ruby (#4543)
* Update twitter-text to version 1.14.7

* Update tilt to version 2.0.8

* Update statsd-instrument to version 2.1.4

* Update sidekiq to version 5.0.4

* Update sidekiq-scheduler to version 2.1.8

* Update sidekiq-unique-jobs to version 5.0.9

* Update redis-activesupport to version 5.0.3

* Update rails-settings-cached to version v0.6.6

* Update pkg-config to version 1.2.4

* Update parallel_tests to version 2.14.2

* Update jsonapi-renderer to version 0.1.3

* Update i18n-tasks to version 0.9.16

* Update httplog to version 0.99.7

* Update fabrication to version 2.16.2

* Update bootsnap to version 1.1.2

* Update aws-sigv4 to version 1.0.1

* Update aws-sdk-core to version 2.10.21

* Update hashdiff to version 0.3.5

* Update rails to version 5.1.3
2017-08-07 18:55:36 +02:00
Thomas Leister
5d408fd9aa [Docker] Add multicore support to "make" and "bundler" (#4544)
* Let make and bundler use multiple cores

* Adds -j option to bundle install instead of bundle config
2017-08-07 18:55:07 +02:00
m4sk1n
47579ec58c It makes no sense to try using invalid or expired link again (#4521)
Signed-off-by: Marcin Mikołajczak <me@m4sk.in>
2017-08-08 00:20:21 +09:00
m4sk1n
3363a05539 i18n: Update Polish translation (#4545) 2017-08-07 21:55:25 +09:00
Yamagishi Kazutoshi
87f10d476c Set false to animated options for thumbnail processor (#4547)
Resolve #3199

Fix the aspect ratio of animated GIF whose background is transparent.
2017-08-07 14:41:21 +02:00
Eugen Rochko
41c3389d76 Bump to 1.5.1 2017-08-06 23:53:25 +02:00
Masoud Abkenar
e7a5a188ef i18n: update Persian translation (#4540) 2017-08-06 23:50:20 +02:00
Komic
71384b2ef9 Fix #4531 (#4533)
wrong character, sorry about that~
2017-08-06 21:52:12 +09:00
Komic
d1d465347a fr.yml update (#4531)
Translation for https://github.com/tootsuite/mastodon/pull/4523
2017-08-06 17:36:10 +09:00
Daigo 3 Dango
5eba129b0f Translate 'Signed in as' into Japanese (#4530) 2017-08-06 03:14:11 +02:00
Quent-in
021a83ead4 l10n OC - Added #4523 "signed in as" (#4529)
Neutral form used.
2017-08-06 08:10:53 +09:00
MitarashiDango
5ee45fa571 fix columns_area.js (#4528) 2017-08-05 20:33:41 +02:00
Eugen Rochko
61a06eb328 Update goldfinger to 2.0.1, see tootsuite/goldfinger#5 (#4527) 2017-08-05 15:29:52 +02:00
Eugen Rochko
df605f0f8b Add "signed in as" header to some pages (#4523) 2017-08-05 04:24:58 +02:00
Quent-in
029786442a l10n update Occitan (#4522)
Salutation mailer + invalid reset link.
2017-08-05 01:31:27 +02:00
Akihiko Odaki
9d1f8b9d6a Scroll columns area to right when children property is changed (#4517)
The feature to pin column could hide the rightmost column, which is
specified with children property of ColumnsArea.

The user is likely to see the column when the property changed, so scroll
the area in such cases.
2017-08-04 18:57:46 +02:00
Krzysztof Jurewicz
400616813e Fix some mistakes in Polish translation (#4495) 2017-08-04 22:43:28 +09:00
Eugen Rochko
724be2d5fe Ignore some locale keys that can but do not need to be translated (#4515) 2017-08-04 04:42:28 +02:00
Jeroen
76da330155 Dutch strings: typo (#4489) 2017-08-04 07:16:25 +09:00
Yamagishi Kazutoshi
ab60aa2266 Use GNU libiconv in Nokogiri (#4494)
System default libiconv of Alpine Linux only supports some charset (e.g. UTF-8).
Therefore, the preview card of the page which is not UTF-8 will be broken in the Docker environment.

Using GNU libiconv!
2017-08-03 18:04:36 +02:00
m4sk1n
0bbd5789b5 i18n: Update Polish translation (#4479)
* i18n: Update Polish translation

* Update Polish translation
2017-08-03 17:49:53 +02:00
Yamagishi Kazutoshi
fae71b653a Enable cache for babel-loader (#4505) 2017-08-03 17:46:49 +02:00
nullkal
dfcd2834f9 Redirect to PasswordController#new when reset_password_token is invalid (#4506) 2017-08-03 17:45:45 +02:00
MIYAGI Hikaru
09e86ef90b make number of comparison in emojify() fewer (#4500)
fix style

"©"削除処理をemojione_lightに移動
2017-08-02 21:05:17 +02:00
TheKinrar
9ba7d526a0 Don't normalize invalid domain names (#4499)
Fixes #4496
2017-08-02 14:54:33 +02:00
unarist
94e233e7b2 Fix column-back-button style for some browsers (#4484)
Use `text-align: unset` instead of `text-align: start` which Edge doesn't support for now.

Also remove default margin on Safari.
2017-08-02 13:09:37 +02:00
Sorin Davidoi
ac53736814 fix(status_list): Use correct keys for keyboard navigation (#4487) 2017-08-02 13:09:09 +02:00
Komic
8c0e78ae43 fr.json update (#4492) 2017-08-02 13:31:49 +09:00
asria-jp
26ab702304 Update Japanese Translation (authorize_follow) (#4481)
* Update Japanese Translation

* preserve "Unfortunately"("残念ながら")

preserve "Unfortunately"("残念ながら")
2017-08-02 11:49:25 +09:00
Quent-in
7ef8482568 l10n #4457 update for Occitan language (#4483)
Previous and Next buttons
2017-08-02 00:12:30 +09:00
471 changed files with 10636 additions and 3042 deletions

View File

@@ -26,7 +26,7 @@ LOCAL_HTTPS=true
# ALTERNATE_DOMAINS=example1.com,example2.com # ALTERNATE_DOMAINS=example1.com,example2.com
# Application secrets # Application secrets
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) # Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
PAPERCLIP_SECRET= PAPERCLIP_SECRET=
SECRET_KEY_BASE= SECRET_KEY_BASE=
OTP_SECRET= OTP_SECRET=
@@ -36,7 +36,7 @@ OTP_SECRET=
# You should only generate this once per instance. If you later decide to change it, all push subscription will # You should only generate this once per instance. If you later decide to change it, all push subscription will
# be invalidated, requiring the users to access the website again to resubscribe. # be invalidated, requiring the users to access the website again to resubscribe.
# #
# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) # Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
# #
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
@@ -98,6 +98,15 @@ SMTP_FROM_ADDRESS=notifications@example.com
# S3_ENDPOINT= # S3_ENDPOINT=
# S3_SIGNATURE_VERSION= # S3_SIGNATURE_VERSION=
# Swift (optional)
# SWIFT_ENABLED=true
# SWIFT_USERNAME=
# SWIFT_TENANT=
# SWIFT_PASSWORD=
# SWIFT_AUTH_URL=
# SWIFT_CONTAINER=
# SWIFT_OBJECT_URL=
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
# S3_CLOUDFRONT_HOST= # S3_CLOUDFRONT_HOST=

View File

@@ -49,6 +49,7 @@ rules:
- warn - warn
- allow: - allow:
- error - error
- warn
no-fallthrough: error no-fallthrough: error
no-irregular-whitespace: error no-irregular-whitespace: error
no-mixed-spaces-and-tabs: warn no-mixed-spaces-and-tabs: warn

View File

@@ -10,6 +10,7 @@ AllCops:
- 'node_modules/**/*' - 'node_modules/**/*'
- 'Vagrantfile' - 'Vagrantfile'
- 'vendor/**/*' - 'vendor/**/*'
- 'lib/json_ld/*'
Bundler/OrderedGems: Bundler/OrderedGems:
Enabled: false Enabled: false

View File

@@ -6,6 +6,7 @@ cache:
- node_modules - node_modules
- public/assets - public/assets
- public/packs-test - public/packs-test
- tmp/cache/babel-loader
dist: trusty dist: trusty
sudo: required sudo: required

15
CODEOWNERS Normal file
View File

@@ -0,0 +1,15 @@
# CODEOWNERS for tootsuite/mastodon
# Translators
# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address.
# /app/javascript/mastodon/locales/fr.json @żelipapą
# /app/views/user_mailer/*.fr.html.erb @żelipapą
# /app/views/user_mailer/*.fr.text.erb @żelipapą
# /config/locales/*.fr.yml @żelipapą
# /config/locales/fr.yml @żelipapą
/app/javascript/mastodon/locales/pl.json @m4sk1n
/app/views/user_mailer/*.pl.html.erb @m4sk1n
/app/views/user_mailer/*.pl.text.erb @m4sk1n
/config/locales/*.pl.yml @m4sk1n
/config/locales/pl.yml @m4sk1n

View File

@@ -1,4 +1,4 @@
FROM ruby:2.4.1-alpine FROM ruby:2.4.1-alpine3.6
LABEL maintainer="https://github.com/tootsuite/mastodon" \ LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="A GNU Social-compatible microblogging server" description="A GNU Social-compatible microblogging server"
@@ -7,19 +7,19 @@ ENV UID=991 GID=991 \
RAILS_SERVE_STATIC_FILES=true \ RAILS_SERVE_STATIC_FILES=true \
RAILS_ENV=production NODE_ENV=production RAILS_ENV=production NODE_ENV=production
ARG LIBICONV_VERSION=1.15
ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178
EXPOSE 3000 4000 EXPOSE 3000 4000
WORKDIR /mastodon WORKDIR /mastodon
RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \ RUN apk -U upgrade \
&& echo "@edge https://nl.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \
&& apk -U upgrade \
&& apk add -t build-dependencies \ && apk add -t build-dependencies \
build-base \ build-base \
icu-dev \ icu-dev \
libidn-dev \ libidn-dev \
libxml2-dev \ libtool \
libxslt-dev \
postgresql-dev \ postgresql-dev \
protobuf-dev \ protobuf-dev \
python \ python \
@@ -29,23 +29,33 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit
file \ file \
git \ git \
icu-libs \ icu-libs \
imagemagick@edge \ imagemagick \
libidn \ libidn \
libpq \ libpq \
libxml2 \ nodejs-npm \
libxslt \ nodejs \
nodejs-npm@edge \
nodejs@edge \
protobuf \ protobuf \
su-exec \ su-exec \
tini \ tini \
yarn@edge \ yarn \
&& update-ca-certificates \ && update-ca-certificates \
&& wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
&& echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \
&& mkdir -p /tmp/src \
&& tar -xzf libiconv.tar.gz -C /tmp/src \
&& rm libiconv.tar.gz \
&& cd /tmp/src/libiconv-$LIBICONV_VERSION \
&& ./configure --prefix=/usr/local \
&& make -j$(getconf _NPROCESSORS_ONLN)\
&& make install \
&& libtool --finish /usr/local/lib \
&& cd /mastodon \
&& rm -rf /tmp/* /var/cache/apk/* && rm -rf /tmp/* /var/cache/apk/*
COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/
RUN bundle install --deployment --without test development \ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
&& yarn --ignore-optional --pure-lockfile && yarn --ignore-optional --pure-lockfile
COPY . /mastodon COPY . /mastodon

View File

@@ -15,6 +15,7 @@ gem 'pghero', '~> 1.7'
gem 'dotenv-rails', '~> 2.2' gem 'dotenv-rails', '~> 2.2'
gem 'aws-sdk', '~> 2.9' gem 'aws-sdk', '~> 2.9'
gem 'fog-openstack', '~> 0.1'
gem 'paperclip', '~> 5.1' gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder', '~> 0.6' gem 'paperclip-av-transcoder', '~> 0.6'
@@ -22,7 +23,8 @@ gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.5' gem 'addressable', '~> 2.5'
gem 'bootsnap' gem 'bootsnap'
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.3' gem 'charlock_holmes', '~> 0.7.5'
gem 'iso-639'
gem 'cld3', '~> 3.1' gem 'cld3', '~> 3.1'
gem 'devise', '~> 4.2' gem 'devise', '~> 4.2'
gem 'devise-two-factor', '~> 3.0' gem 'devise-two-factor', '~> 3.0'
@@ -68,6 +70,9 @@ gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 2.0' gem 'webpacker', '~> 2.0'
gem 'webpush' gem 'webpush'
gem 'json-ld-preloaded', '~> 2.2.1'
gem 'rdf-normalize', '~> 0.3.1'
group :development, :test do group :development, :test do
gem 'fabrication', '~> 2.16' gem 'fabrication', '~> 2.16'
gem 'fuubar', '~> 2.2' gem 'fuubar', '~> 2.2'

View File

@@ -1,25 +1,25 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (5.1.2) actioncable (5.1.3)
actionpack (= 5.1.2) actionpack (= 5.1.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (~> 0.6.1) websocket-driver (~> 0.6.1)
actionmailer (5.1.2) actionmailer (5.1.3)
actionpack (= 5.1.2) actionpack (= 5.1.3)
actionview (= 5.1.2) actionview (= 5.1.3)
activejob (= 5.1.2) activejob (= 5.1.3)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (5.1.2) actionpack (5.1.3)
actionview (= 5.1.2) actionview (= 5.1.3)
activesupport (= 5.1.2) activesupport (= 5.1.3)
rack (~> 2.0) rack (~> 2.0)
rack-test (~> 0.6.3) rack-test (~> 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2) rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.1.2) actionview (5.1.3)
activesupport (= 5.1.2) activesupport (= 5.1.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
@@ -30,22 +30,22 @@ GEM
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.2)
active_record_query_trace (1.5.4) active_record_query_trace (1.5.4)
activejob (5.1.2) activejob (5.1.3)
activesupport (= 5.1.2) activesupport (= 5.1.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (5.1.2) activemodel (5.1.3)
activesupport (= 5.1.2) activesupport (= 5.1.3)
activerecord (5.1.2) activerecord (5.1.3)
activemodel (= 5.1.2) activemodel (= 5.1.3)
activesupport (= 5.1.2) activesupport (= 5.1.3)
arel (~> 8.0) arel (~> 8.0)
activesupport (5.1.2) activesupport (5.1.3)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (~> 0.7) i18n (~> 0.7)
minitest (~> 5.1) minitest (~> 5.1)
tzinfo (~> 1.1) tzinfo (~> 1.1)
addressable (2.5.1) addressable (2.5.2)
public_suffix (~> 2.0, >= 2.0.2) public_suffix (>= 2.0.2, < 4.0)
airbrussh (1.3.0) airbrussh (1.3.0)
sshkit (>= 1.6.1, != 1.7.0) sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.2) annotate (2.7.2)
@@ -57,14 +57,14 @@ GEM
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
aws-sdk (2.10.6) aws-sdk (2.10.21)
aws-sdk-resources (= 2.10.6) aws-sdk-resources (= 2.10.21)
aws-sdk-core (2.10.6) aws-sdk-core (2.10.21)
aws-sigv4 (~> 1.0) aws-sigv4 (~> 1.0)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-resources (2.10.6) aws-sdk-resources (2.10.21)
aws-sdk-core (= 2.10.6) aws-sdk-core (= 2.10.21)
aws-sigv4 (1.0.0) aws-sigv4 (1.0.1)
bcrypt (3.1.11) bcrypt (3.1.11)
better_errors (2.1.1) better_errors (2.1.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
@@ -72,15 +72,15 @@ GEM
rack (>= 0.9.0) rack (>= 0.9.0)
binding_of_caller (0.7.2) binding_of_caller (0.7.2)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
bootsnap (1.1.1) bootsnap (1.1.2)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (3.6.2) brakeman (3.7.2)
browser (2.4.0) browser (2.4.0)
builder (3.2.3) builder (3.2.3)
bullet (5.5.1) bullet (5.5.1)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0) uniform_notifier (~> 1.10.0)
bundler-audit (0.5.0) bundler-audit (0.6.0)
bundler (~> 1.2) bundler (~> 1.2)
thor (~> 0.18) thor (~> 0.18)
capistrano (3.8.2) capistrano (3.8.2)
@@ -108,7 +108,7 @@ GEM
xpath (~> 2.0) xpath (~> 2.0)
case_transform (0.2) case_transform (0.2)
activesupport activesupport
charlock_holmes (0.7.3) charlock_holmes (0.7.5)
chunky_png (1.3.8) chunky_png (1.3.8)
cld3 (3.1.3) cld3 (3.1.3)
ffi (>= 1.1.0, < 1.10.0) ffi (>= 1.1.0, < 1.10.0)
@@ -154,18 +154,31 @@ GEM
erubis (2.7.0) erubis (2.7.0)
et-orbi (1.0.5) et-orbi (1.0.5)
tzinfo tzinfo
excon (0.58.0)
execjs (2.7.0) execjs (2.7.0)
fabrication (2.16.1) fabrication (2.16.2)
faker (1.7.3) faker (1.7.3)
i18n (~> 0.5) i18n (~> 0.5)
fast_blank (1.0.0) fast_blank (1.0.0)
ffi (1.9.18) ffi (1.9.18)
fog-core (1.45.0)
builder
excon (~> 0.58)
formatador (~> 0.2)
fog-json (1.0.2)
fog-core (~> 1.0)
multi_json (~> 1.10)
fog-openstack (0.1.21)
fog-core (>= 1.40)
fog-json (>= 1.0)
ipaddress (>= 0.8)
formatador (0.2.5)
fuubar (2.2.0) fuubar (2.2.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (0.4.0) globalid (0.4.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldfinger (2.0.0) goldfinger (2.0.1)
addressable (~> 2.5) addressable (~> 2.5)
http (~> 2.2) http (~> 2.2)
nokogiri (~> 1.8) nokogiri (~> 1.8)
@@ -179,7 +192,9 @@ GEM
activesupport (>= 4.0.1) activesupport (>= 4.0.1)
hamlit (>= 1.2.0) hamlit (>= 1.2.0)
railties (>= 4.0.1) railties (>= 4.0.1)
hashdiff (0.3.4) hamster (3.0.0)
concurrent-ruby (~> 1.0)
hashdiff (0.3.5)
highline (1.7.8) highline (1.7.8)
hiredis (0.6.1) hiredis (0.6.1)
hkdf (0.3.0) hkdf (0.3.0)
@@ -194,11 +209,11 @@ GEM
http-form_data (1.0.3) http-form_data (1.0.3)
http_accept_language (2.1.1) http_accept_language (2.1.1)
http_parser.rb (0.6.0) http_parser.rb (0.6.0)
httplog (0.99.4) httplog (0.99.7)
colorize colorize
rack rack
i18n (0.8.4) i18n (0.8.6)
i18n-tasks (0.9.15) i18n-tasks (0.9.16)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
ast (>= 2.1.0) ast (>= 2.1.0)
easy_translate (>= 0.5.0) easy_translate (>= 0.5.0)
@@ -209,9 +224,18 @@ GEM
rainbow (~> 2.2) rainbow (~> 2.2)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.0) idn-ruby (0.1.0)
ipaddress (0.8.3)
iso-639 (0.2.8)
jmespath (1.3.1) jmespath (1.3.1)
json (2.1.0) json (2.1.0)
jsonapi-renderer (0.1.2) json-ld (2.1.5)
multi_json (~> 1.12)
rdf (~> 2.2)
json-ld-preloaded (2.2.1)
json-ld (~> 2.1, >= 2.1.5)
multi_json (~> 1.11)
rdf (~> 2.2)
jsonapi-renderer (0.1.3)
jwt (1.5.6) jwt (1.5.6)
kaminari (1.0.1) kaminari (1.0.1)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
@@ -253,7 +277,7 @@ GEM
mime-types-data (3.2016.0521) mime-types-data (3.2016.0521)
mimemagic (0.3.2) mimemagic (0.3.2)
mini_portile2 (2.2.0) mini_portile2 (2.2.0)
minitest (5.10.2) minitest (5.10.3)
msgpack (1.1.0) msgpack (1.1.0)
multi_json (1.12.1) multi_json (1.12.1)
net-scp (1.2.1) net-scp (1.2.1)
@@ -264,7 +288,7 @@ GEM
mini_portile2 (~> 2.2.0) mini_portile2 (~> 2.2.0)
nokogumbo (1.4.13) nokogumbo (1.4.13)
nokogiri nokogiri
oj (3.2.0) oj (3.3.4)
openssl (2.0.4) openssl (2.0.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostatus2 (2.0.1) ostatus2 (2.0.1)
@@ -283,14 +307,14 @@ GEM
av (~> 0.9.0) av (~> 0.9.0)
paperclip (>= 2.5.2) paperclip (>= 2.5.2)
parallel (1.11.2) parallel (1.11.2)
parallel_tests (2.14.1) parallel_tests (2.14.2)
parallel parallel
parser (2.4.0.0) parser (2.4.0.0)
ast (~> 2.2) ast (~> 2.2)
pg (0.21.0) pg (0.21.0)
pghero (1.7.0) pghero (1.7.0)
activerecord activerecord
pkg-config (1.2.3) pkg-config (1.2.4)
powerpack (0.1.1) powerpack (0.1.1)
pry (0.10.4) pry (0.10.4)
coderay (~> 1.1.0) coderay (~> 1.1.0)
@@ -298,7 +322,7 @@ GEM
slop (~> 3.4) slop (~> 3.4)
pry-rails (0.3.6) pry-rails (0.3.6)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (2.0.5) public_suffix (3.0.0)
puma (3.9.1) puma (3.9.1)
pundit (1.1.0) pundit (1.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@@ -313,17 +337,17 @@ GEM
rack-test (0.6.3) rack-test (0.6.3)
rack (>= 1.0) rack (>= 1.0)
rack-timeout (0.4.2) rack-timeout (0.4.2)
rails (5.1.2) rails (5.1.3)
actioncable (= 5.1.2) actioncable (= 5.1.3)
actionmailer (= 5.1.2) actionmailer (= 5.1.3)
actionpack (= 5.1.2) actionpack (= 5.1.3)
actionview (= 5.1.2) actionview (= 5.1.3)
activejob (= 5.1.2) activejob (= 5.1.3)
activemodel (= 5.1.2) activemodel (= 5.1.3)
activerecord (= 5.1.2) activerecord (= 5.1.3)
activesupport (= 5.1.2) activesupport (= 5.1.3)
bundler (>= 1.3.0, < 2.0) bundler (>= 1.3.0)
railties (= 5.1.2) railties (= 5.1.3)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.2) rails-controller-testing (1.0.2)
actionpack (~> 5.x, >= 5.0.1) actionpack (~> 5.x, >= 5.0.1)
@@ -337,23 +361,28 @@ GEM
rails-i18n (5.0.4) rails-i18n (5.0.4)
i18n (~> 0.7) i18n (~> 0.7)
railties (~> 5.0) railties (~> 5.0)
rails-settings-cached (0.6.5) rails-settings-cached (0.6.6)
rails (>= 4.2.0) rails (>= 4.2.0)
railties (5.1.2) railties (5.1.3)
actionpack (= 5.1.2) actionpack (= 5.1.3)
activesupport (= 5.1.2) activesupport (= 5.1.3)
method_source method_source
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0) thor (>= 0.18.1, < 2.0)
rainbow (2.2.2) rainbow (2.2.2)
rake rake
rake (12.0.0) rake (12.0.0)
rdf (2.2.8)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.2)
rdf (~> 2.0)
redis (3.3.3) redis (3.3.3)
redis-actionpack (5.0.1) redis-actionpack (5.0.1)
actionpack (>= 4.0, < 6) actionpack (>= 4.0, < 6)
redis-rack (>= 1, < 3) redis-rack (>= 1, < 3)
redis-store (>= 1.1.0, < 1.4.0) redis-store (>= 1.1.0, < 1.4.0)
redis-activesupport (5.0.2) redis-activesupport (5.0.3)
activesupport (>= 3, < 6) activesupport (>= 3, < 6)
redis-store (~> 1.3.0) redis-store (~> 1.3.0)
redis-namespace (1.5.3) redis-namespace (1.5.3)
@@ -413,7 +442,7 @@ GEM
scss_lint (0.54.0) scss_lint (0.54.0)
rake (>= 0.9, < 13) rake (>= 0.9, < 13)
sass (~> 3.4.20) sass (~> 3.4.20)
sidekiq (5.0.3) sidekiq (5.0.4)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0) connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0) rack-protection (>= 1.5.0)
@@ -421,12 +450,12 @@ GEM
sidekiq-bulk (0.1.1) sidekiq-bulk (0.1.1)
activesupport activesupport
sidekiq sidekiq
sidekiq-scheduler (2.1.7) sidekiq-scheduler (2.1.8)
redis (~> 3) redis (~> 3)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 3) sidekiq (>= 3)
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (5.0.8) sidekiq-unique-jobs (5.0.9)
sidekiq (>= 4.0, <= 6.0) sidekiq (>= 4.0, <= 6.0)
thor (~> 0) thor (~> 0)
simple-navigation (4.0.5) simple-navigation (4.0.5)
@@ -450,15 +479,15 @@ GEM
sshkit (1.13.1) sshkit (1.13.1)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
statsd-instrument (2.1.2) statsd-instrument (2.1.4)
temple (0.8.0) temple (0.8.0)
terminal-table (1.8.0) terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (~> 1.1, >= 1.1.1)
thor (0.19.4) thor (0.20.0)
thread (0.2.2) thread (0.2.2)
thread_safe (0.3.6) thread_safe (0.3.6)
tilt (2.0.7) tilt (2.0.8)
twitter-text (1.14.6) twitter-text (1.14.7)
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.3) tzinfo (1.2.3)
thread_safe (~> 0.1) thread_safe (~> 0.1)
@@ -511,7 +540,7 @@ DEPENDENCIES
capistrano-rbenv (~> 2.1) capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 2.14) capybara (~> 2.14)
charlock_holmes (~> 0.7.3) charlock_holmes (~> 0.7.5)
cld3 (~> 3.1) cld3 (~> 3.1)
climate_control (~> 0.2) climate_control (~> 0.2)
devise (~> 4.2) devise (~> 4.2)
@@ -521,6 +550,7 @@ DEPENDENCIES
fabrication (~> 2.16) fabrication (~> 2.16)
faker (~> 1.7) faker (~> 1.7)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fog-openstack (~> 0.1)
fuubar (~> 2.2) fuubar (~> 2.2)
goldfinger (~> 2.0) goldfinger (~> 2.0)
hamlit-rails (~> 0.2) hamlit-rails (~> 0.2)
@@ -531,6 +561,8 @@ DEPENDENCIES
httplog (~> 0.99) httplog (~> 0.99)
i18n-tasks (~> 0.9) i18n-tasks (~> 0.9)
idn-ruby idn-ruby
iso-639
json-ld-preloaded (~> 2.2.1)
kaminari (~> 1.0) kaminari (~> 1.0)
letter_opener (~> 1.4) letter_opener (~> 1.4)
letter_opener_web (~> 1.3) letter_opener_web (~> 1.3)
@@ -560,6 +592,7 @@ DEPENDENCIES
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 5.0) rails-i18n (~> 5.0)
rails-settings-cached (~> 0.6) rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.3.1)
redis (~> 3.3) redis (~> 3.3)
redis-namespace (~> 1.5) redis-namespace (~> 1.5)
redis-rails (~> 5.0) redis-rails (~> 5.0)
@@ -590,4 +623,4 @@ RUBY VERSION
ruby 2.4.1p111 ruby 2.4.1p111
BUNDLED WITH BUNDLED WITH
1.15.2 1.15.4

View File

@@ -7,24 +7,78 @@ class AccountsController < ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html do
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) @pinned_statuses = []
@statuses = cache_collection(@statuses, Status)
if current_account && @account.blocking?(current_account)
@statuses = []
return
end
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
@statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
@next_url = next_url unless @statuses.empty?
end end
format.atom do format.atom do
@entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a)) render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? }))
end end
format.json do format.json do
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
end end
end end
private private
def show_pinned_statuses?
[replies_requested?, media_requested?, params[:max_id].present?, params[:since_id].present?].none?
end
def filtered_statuses
default_statuses.tap do |statuses|
statuses.merge!(only_media_scope) if media_requested?
statuses.merge!(no_replies_scope) unless replies_requested?
end
end
def default_statuses
@account.statuses.where(visibility: [:public, :unlisted])
end
def only_media_scope
Status.where(id: account_media_status_ids)
end
def account_media_status_ids
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct
end
def no_replies_scope
Status.without_replies
end
def set_account def set_account
@account = Account.find_local!(params[:username]) @account = Account.find_local!(params[:username])
end end
def next_url
if media_requested?
short_account_media_url(@account, max_id: @statuses.last.id)
elsif replies_requested?
short_account_with_replies_url(@account, max_id: @statuses.last.id)
else
short_account_url(@account, max_id: @statuses.last.id)
end
end
def media_requested?
request.path.ends_with?('/media')
end
def replies_requested?
request.path.ends_with?('/with_replies')
end
end end

View File

@@ -0,0 +1,40 @@
# frozen_string_literal: true
class ActivityPub::InboxesController < Api::BaseController
include SignatureVerification
before_action :set_account
def create
if signed_request_account
upgrade_account
process_payload
head 201
else
head 202
end
end
private
def set_account
@account = Account.find_local!(params[:account_username]) if params[:account_username]
end
def body
@body ||= request.body.read
end
def upgrade_account
if signed_request_account.ostatus?
signed_request_account.update(last_webfingered_at: nil)
ResolveRemoteAccountWorker.perform_async(signed_request_account.acct)
end
Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed?
end
def process_payload
ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
end
end

View File

@@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status) @statuses = cache_collection(@statuses, Status)
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
private private

View File

@@ -17,7 +17,7 @@ module Admin
end end
def unsubscribe def unsubscribe
UnsubscribeService.new.call(@account) Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id)
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id)
end end

View File

@@ -13,6 +13,7 @@ module Admin
closed_registrations_message closed_registrations_message
open_deletion open_deletion
timeline_preview timeline_preview
bootstrap_timeline_accounts
).freeze ).freeze
BOOLEAN_SETTINGS = %w( BOOLEAN_SETTINGS = %w(

View File

@@ -9,7 +9,7 @@ module Admin
before_action :set_account before_action :set_account
before_action :set_status, only: [:update, :destroy] before_action :set_status, only: [:update, :destroy]
PAR_PAGE = 20 PER_PAGE = 20
def index def index
@statuses = @account.statuses @statuses = @account.statuses
@@ -17,7 +17,7 @@ module Admin
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
@statuses.merge!(Status.where(id: account_media_status_ids)) @statuses.merge!(Status.where(id: account_media_status_ids))
end end
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE) @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
@form = Form::StatusBatch.new @form = Form::StatusBatch.new
end end

View File

@@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController
links = [] links = []
links << [next_path, [%w(rel next)]] if next_path links << [next_path, [%w(rel next)]] if next_path
links << [prev_path, [%w(rel prev)]] if prev_path links << [prev_path, [%w(rel prev)]] if prev_path
response.headers['Link'] = LinkHeader.new(links) response.headers['Link'] = LinkHeader.new(links) unless links.empty?
end end
def limit_param(default_limit) def limit_param(default_limit)
@@ -62,10 +62,11 @@ class Api::BaseController < ApplicationController
end end
def require_user! def require_user!
current_resource_owner if current_user
set_user_activity set_user_activity
rescue ActiveRecord::RecordNotFound else
render json: { error: 'This method requires an authenticated user' }, status: 422 render json: { error: 'This method requires an authenticated user' }, status: 422
end
end end
def render_empty def render_empty

View File

@@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController
respond_to :json respond_to :json
def show def show
@stream_entry = find_stream_entry.stream_entry @status = status_finder.status
render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
end end
private private
def find_stream_entry def status_finder
StreamEntryFinder.new(params[:url]) StatusFinder.new(params[:url])
end end
def maxwidth_or_default def maxwidth_or_default

View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Accounts::CredentialsController < Api::BaseController class Api::V1::Accounts::CredentialsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }, except: [:update]
before_action -> { doorkeeper_authorize! :write }, only: [:update] before_action -> { doorkeeper_authorize! :write }, only: [:update]
before_action :require_user! before_action :require_user!
@@ -10,8 +11,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
end end
def update def update
current_account.update!(account_params)
@account = current_account @account = current_account
UpdateAccountService.new.call(@account, account_params, raise_error: true)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
render json: @account, serializer: REST::CredentialAccountSerializer render json: @account, serializer: REST::CredentialAccountSerializer
end end

View File

@@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def account_statuses def account_statuses
default_statuses.tap do |statuses| default_statuses.tap do |statuses|
statuses.merge!(only_media_scope) if params[:only_media] statuses.merge!(only_media_scope) if params[:only_media]
statuses.merge!(pinned_scope) if params[:pinned]
statuses.merge!(no_replies_scope) if params[:exclude_replies] statuses.merge!(no_replies_scope) if params[:exclude_replies]
end end
end end
@@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
end end
def pinned_scope
@account.pinned_statuses
end
def no_replies_scope def no_replies_scope
Status.without_replies Status.without_replies
end end

View File

@@ -14,6 +14,16 @@ class Api::V1::AccountsController < Api::BaseController
def follow def follow
FollowService.new.call(current_user.account, @account.acct) FollowService.new.call(current_user.account, @account.acct)
unless @account.locked?
relationships = AccountRelationshipsPresenter.new(
[@account.id],
current_user.account_id,
following_map: { @account.id => true },
requested_map: { @account.id => false }
)
end
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end end

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
class Api::V1::Statuses::PinsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write }
before_action :require_user!
before_action :set_status
respond_to :json
def create
StatusPin.create!(account: current_account, status: @status)
render json: @status, serializer: REST::StatusSerializer
end
def destroy
pin = StatusPin.find_by(account: current_account, status: @status)
pin&.destroy!
render json: @status, serializer: REST::StatusSerializer
end
private
def set_status
@status = Status.find(params[:status_id])
end
end

View File

@@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController
end end
def card def card
@card = PreviewCard.find_by(status: @status) @card = @status.preview_cards.first
if @card.nil? if @card.nil?
render_empty render_empty

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
class Api::Web::EmbedsController < Api::BaseController
respond_to :json
before_action :require_user!
def create
status = StatusFinder.new(params[:url]).status
render json: status, serializer: OEmbedSerializer, width: 400
rescue ActiveRecord::RecordNotFound
oembed = OEmbed::Providers.get(params[:url])
render json: Oj.dump(oembed.fields)
rescue OEmbed::NotFound
render json: {}, status: :not_found
end
end

View File

@@ -43,6 +43,10 @@ class ApplicationController < ActionController::Base
forbidden if current_user.account.suspended? forbidden if current_user.account.suspended?
end end
def after_sign_out_path_for(_resource_or_scope)
new_user_session_path
end
protected protected
def forbidden def forbidden

View File

@@ -2,4 +2,10 @@
class Auth::ConfirmationsController < Devise::ConfirmationsController class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth' layout 'auth'
def show
super do |user|
BootstrapTimelineWorker.perform_async(user.account_id) if user.errors.empty?
end
end
end end

View File

@@ -1,5 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::PasswordsController < Devise::PasswordsController class Auth::PasswordsController < Devise::PasswordsController
before_action :check_validity_of_reset_password_token, only: :edit
layout 'auth' layout 'auth'
private
def check_validity_of_reset_password_token
unless reset_password_token_is_valid?
flash[:error] = I18n.t('auth.invalid_reset_password_token')
redirect_to new_password_path(resource_name)
end
end
def reset_password_token_is_valid?
resource_class.with_reset_password_token(params[:reset_password_token]).present?
end
end end

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class AuthorizeFollowsController < ApplicationController class AuthorizeFollowsController < ApplicationController
layout 'public' layout 'modal'
before_action :authenticate_user! before_action :authenticate_user!

View File

@@ -23,6 +23,7 @@ module AccountControllerConcern
[ [
webfinger_account_link, webfinger_account_link,
atom_account_url_link, atom_account_url_link,
actor_url_link,
] ]
) )
end end
@@ -41,6 +42,13 @@ module AccountControllerConcern
] ]
end end
def actor_url_link
[
ActivityPub::TagManager.instance.uri_for(@account),
[%w(rel alternate), %w(type application/activity+json)],
]
end
def webfinger_account_url def webfinger_account_url
webfinger_url(resource: @account.to_webfinger_s) webfinger_url(resource: @account.to_webfinger_s)
end end

View File

@@ -31,7 +31,7 @@ module SignatureVerification
return return
end end
account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) account = account_from_key_id(signature_params['keyId'])
if account.nil? if account.nil?
@signed_request_account = nil @signed_request_account = nil
@@ -49,6 +49,10 @@ module SignatureVerification
end end
end end
def request_body
@request_body ||= request.raw_post
end
private private
def build_signed_string(signed_headers) def build_signed_string(signed_headers)
@@ -57,6 +61,8 @@ module SignatureVerification
signed_headers.split(' ').map do |signed_header| signed_headers.split(' ').map do |signed_header|
if signed_header == Request::REQUEST_TARGET if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
elsif signed_header == 'digest'
"digest: #{body_digest}"
else else
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}" "#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
end end
@@ -73,6 +79,10 @@ module SignatureVerification
(Time.now.utc - time_sent).abs <= 30 (Time.now.utc - time_sent).abs <= 30
end end
def body_digest
"SHA-256=#{Digest::SHA256.base64digest(request_body)}"
end
def to_header_name(name) def to_header_name(name)
name.split(/-/).map(&:capitalize).join('-') name.split(/-/).map(&:capitalize).join('-')
end end
@@ -81,7 +91,16 @@ module SignatureVerification
signature_params['keyId'].blank? || signature_params['keyId'].blank? ||
signature_params['signature'].blank? || signature_params['signature'].blank? ||
signature_params['algorithm'].blank? || signature_params['algorithm'].blank? ||
signature_params['algorithm'] != 'rsa-sha256' || signature_params['algorithm'] != 'rsa-sha256'
!signature_params['keyId'].start_with?('acct:') end
def account_from_key_id(key_id)
if key_id.start_with?('acct:')
ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id)
account
end
end end
end end

View File

@@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController
format.html format.html
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
end end
end end

View File

@@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController
format.html format.html
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
end end
end end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class IntentsController < ApplicationController
def show
uri = Addressable::URI.parse(params[:uri])
if uri.scheme == 'web+mastodon'
case uri.host
when 'follow'
return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
when 'share'
return redirect_to share_path(text: uri.query_values['text'])
end
end
not_found
end
end

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class RemoteFollowController < ApplicationController class RemoteFollowController < ApplicationController
layout 'public' layout 'modal'
before_action :set_account before_action :set_account
before_action :gone, if: :suspended_account? before_action :gone, if: :suspended_account?

View File

@@ -0,0 +1,72 @@
# frozen_string_literal: true
class Settings::ApplicationsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_application, only: [:show, :update, :destroy, :regenerate]
before_action :prepare_scopes, only: [:create, :update]
def index
@applications = current_user.applications.page(params[:page])
end
def new
@application = Doorkeeper::Application.new(
redirect_uri: Doorkeeper.configuration.native_redirect_uri,
scopes: 'read write follow'
)
end
def show; end
def create
@application = current_user.applications.build(application_params)
if @application.save
redirect_to settings_applications_path, notice: I18n.t('applications.created')
else
render :new
end
end
def update
if @application.update(application_params)
redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end
def destroy
@application.destroy
redirect_to settings_applications_path, notice: I18n.t('applications.destroyed')
end
def regenerate
@access_token = current_user.token_for_app(@application)
@access_token.destroy
redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated')
end
private
def set_application
@application = current_user.applications.find(params[:id])
end
def application_params
params.require(:doorkeeper_application).permit(
:name,
:redirect_uri,
:scopes,
:website
)
end
def prepare_scopes
scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array
end
end

View File

@@ -14,7 +14,8 @@ class Settings::ProfilesController < ApplicationController
def show; end def show; end
def update def update
if @account.update(account_params) if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
else else
render :show render :show

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
class SharesController < ApplicationController
layout 'modal'
before_action :authenticate_user!
before_action :set_body_classes
def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
private
def initial_state_params
{
settings: Web::Setting.find_by(user: current_user)&.data || {},
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username),
text: params[:text],
}
end
def set_body_classes
@body_classes = 'compose-standalone'
end
end

View File

@@ -9,6 +9,7 @@ class StatusesController < ApplicationController
before_action :set_status before_action :set_status
before_action :set_link_headers before_action :set_link_headers
before_action :check_account_suspension before_action :check_account_suspension
before_action :redirect_to_original, only: [:show]
def show def show
respond_to do |format| respond_to do |format|
@@ -20,13 +21,18 @@ class StatusesController < ApplicationController
end end
format.json do format.json do
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
end end
end end
def activity def activity
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
def embed
response.headers['X-Frame-Options'] = 'ALLOWALL'
render 'stream_entries/embed', layout: 'embedded'
end end
private private
@@ -36,7 +42,12 @@ class StatusesController < ApplicationController
end end
def set_link_headers def set_link_headers
response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) response.headers['Link'] = LinkHeader.new(
[
[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]],
]
)
end end
def set_status def set_status
@@ -53,4 +64,8 @@ class StatusesController < ApplicationController
def check_account_suspension def check_account_suspension
gone if @account.suspended? gone if @account.suspended?
end end
def redirect_to_original
redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog?
end
end end

View File

@@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController
end end
def embed def embed
response.headers['X-Frame-Options'] = 'ALLOWALL' redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301
return gone if @stream_entry.activity.nil?
render layout: 'embedded'
end end
private private
@@ -38,7 +35,12 @@ class StreamEntriesController < ApplicationController
end end
def set_link_headers def set_link_headers
response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) response.headers['Link'] = LinkHeader.new(
[
[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
[ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]],
]
)
end end
def set_stream_entry def set_stream_entry

View File

@@ -12,7 +12,7 @@ class TagsController < ApplicationController
format.html format.html
format.json do format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
end end
end end

View File

@@ -5,6 +5,10 @@ module ApplicationHelper
current_page?(path) ? 'active' : '' current_page?(path) ? 'active' : ''
end end
def active_link_to(label, path, options = {})
link_to label, path, options.merge(class: active_nav_class(path))
end
def show_landing_strip? def show_landing_strip?
!user_signed_in? && !single_user_mode? !user_signed_in? && !single_user_mode?
end end

View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
module JsonLdHelper
def equals_or_includes?(haystack, needle)
haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
end
def first_of_value(value)
value.is_a?(Array) ? value.first : value
end
def value_or_id(value)
value.is_a?(String) || value.nil? ? value : value['id']
end
def supported_context?(json)
!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
end
def canonicalize(json)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
graph.dump(:normalize)
end
def fetch_resource(uri)
response = build_request(uri).perform
return if response.code != 200
body_to_json(response.to_s)
end
def body_to_json(body)
body.is_a?(String) ? Oj.load(body, mode: :strict) : body
rescue Oj::ParseError
nil
end
def merge_context(context, new_context)
if context.is_a?(Array)
context << new_context
else
[context, new_context]
end
end
private
def build_request(uri)
request = Request.new(:get, uri)
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
request
end
end

View File

@@ -12,6 +12,14 @@ module RoutingHelper
end end
def full_asset_url(source, options = {}) def full_asset_url(source, options = {})
Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s source = ActionController::Base.helpers.asset_url(source, options) unless use_storage?
URI.join(root_url, source).to_s
end
private
def use_storage?
Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift
end end
end end

View File

@@ -30,6 +30,7 @@ module SettingsHelper
th: 'ภาษาไทย', th: 'ภาษาไทย',
tr: 'Türkçe', tr: 'Türkçe',
uk: 'Українська', uk: 'Українська',
zh: '中文',
'zh-CN': '简体中文', 'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)', 'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)', 'zh-TW': '繁體中文(臺灣)',
@@ -39,6 +40,10 @@ module SettingsHelper
HUMAN_LOCALES[locale] HUMAN_LOCALES[locale]
end end
def filterable_languages
I18n.available_locales.map { |locale| locale.to_s.split('-').first.to_sym }.uniq
end
def hash_to_object(hash) def hash_to_object(hash)
HashObject.new(hash) HashObject.new(hash)
end end

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module StreamEntriesHelper module StreamEntriesHelper
EMBEDDED_CONTROLLER = 'stream_entries' EMBEDDED_CONTROLLER = 'statuses'
EMBEDDED_ACTION = 'embed' EMBEDDED_ACTION = 'embed'
def display_name(account) def display_name(account)

View File

@@ -1,6 +1,11 @@
import api from '../api'; import api from '../api';
import { updateTimeline } from './timelines'; import {
updateTimeline,
refreshHomeTimeline,
refreshCommunityTimeline,
refreshPublicTimeline,
} from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
@@ -95,16 +100,20 @@ export function submitCompose() {
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
// To make the app more responsive, immediately get the status into the columns // To make the app more responsive, immediately get the status into the columns
dispatch(updateTimeline('home', { ...response.data }));
const insertOrRefresh = (timelineId, refreshAction) => {
if (getState().getIn(['timelines', timelineId, 'online'])) {
dispatch(updateTimeline(timelineId, { ...response.data }));
} else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
dispatch(refreshAction());
}
};
insertOrRefresh('home', refreshHomeTimeline);
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
if (getState().getIn(['timelines', 'community', 'loaded'])) { insertOrRefresh('community', refreshCommunityTimeline);
dispatch(updateTimeline('community', { ...response.data })); insertOrRefresh('public', refreshPublicTimeline);
}
if (getState().getIn(['timelines', 'public', 'loaded'])) {
dispatch(updateTimeline('public', { ...response.data }));
}
} }
}).catch(function (error) { }).catch(function (error) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(error));

View File

@@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS';
export const PIN_FAIL = 'PIN_FAIL';
export const UNPIN_REQUEST = 'UNPIN_REQUEST';
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
export const UNPIN_FAIL = 'UNPIN_FAIL';
export function reblog(status) { export function reblog(status) {
return function (dispatch, getState) { return function (dispatch, getState) {
dispatch(reblogRequest(status)); dispatch(reblogRequest(status));
@@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) {
error, error,
}; };
}; };
export function pin(status) {
return (dispatch, getState) => {
dispatch(pinRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
dispatch(pinSuccess(status, response.data));
}).catch(error => {
dispatch(pinFail(status, error));
});
};
};
export function pinRequest(status) {
return {
type: PIN_REQUEST,
status,
};
};
export function pinSuccess(status, response) {
return {
type: PIN_SUCCESS,
status,
response,
};
};
export function pinFail(status, error) {
return {
type: PIN_FAIL,
status,
error,
};
};
export function unpin (status) {
return (dispatch, getState) => {
dispatch(unpinRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
dispatch(unpinSuccess(status, response.data));
}).catch(error => {
dispatch(unpinFail(status, error));
});
};
};
export function unpinRequest(status) {
return {
type: UNPIN_REQUEST,
status,
};
};
export function unpinSuccess(status, response) {
return {
type: UNPIN_SUCCESS,
status,
response,
};
};
export function unpinFail(status, error) {
return {
type: UNPIN_FAIL,
status,
error,
};
};

View File

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

View File

@@ -23,6 +23,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
export const STATUS_SET_HEIGHT = 'STATUS_SET_HEIGHT';
export const STATUSES_CLEAR_HEIGHT = 'STATUSES_CLEAR_HEIGHT';
export function fetchStatusRequest(id, skipLoading) { export function fetchStatusRequest(id, skipLoading) {
return { return {
type: STATUS_FETCH_REQUEST, type: STATUS_FETCH_REQUEST,
@@ -215,3 +218,17 @@ export function unmuteStatusFail(id, error) {
error, error,
}; };
}; };
export function setStatusHeight (id, height) {
return {
type: STATUS_SET_HEIGHT,
id,
height,
};
};
export function clearStatusesHeight () {
return {
type: STATUSES_CLEAR_HEIGHT,
};
};

View File

@@ -0,0 +1,94 @@
import createStream from '../stream';
import {
updateTimeline,
deleteFromTimelines,
refreshHomeTimeline,
connectTimeline,
disconnectTimeline,
} from './timelines';
import { updateNotifications, refreshNotifications } from './notifications';
import { getLocale } from '../locales';
const { messages } = getLocale();
export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
return (dispatch, getState) => {
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = getState().getIn(['meta', 'access_token']);
const locale = getState().getIn(['meta', 'locale']);
let polling = null;
const setupPolling = () => {
polling = setInterval(() => {
pollingRefresh(dispatch);
}, 20000);
};
const clearPolling = () => {
if (polling) {
clearInterval(polling);
polling = null;
}
};
const subscription = createStream(streamingAPIBaseURL, accessToken, path, {
connected () {
if (pollingRefresh) {
clearPolling();
}
dispatch(connectTimeline(timelineId));
},
disconnected () {
if (pollingRefresh) {
setupPolling();
}
dispatch(disconnectTimeline(timelineId));
},
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
case 'notification':
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;
}
},
reconnected () {
if (pollingRefresh) {
clearPolling();
pollingRefresh(dispatch);
}
dispatch(connectTimeline(timelineId));
},
});
const disconnect = () => {
if (subscription) {
subscription.close();
}
clearPolling();
};
return disconnect;
};
}
function refreshHomeTimelineAndNotification (dispatch) {
dispatch(refreshHomeTimeline());
dispatch(refreshNotifications());
}
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
export const connectPublicStream = () => connectTimelineStream('public', 'public');
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);

View File

@@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent {
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
}; };
handleFollow = () => { handleFollow = () => {
@@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent {
} }
render () { render () {
const { account, me, intl } = this.props; const { account, me, intl, hidden } = this.props;
if (!account) { if (!account) {
return <div />; return <div />;
} }
if (hidden) {
return (
<div>
{account.get('display_name')}
{account.get('username')}
</div>
);
}
let buttons; let buttons;
if (account.get('id') !== me && account.get('relationship', null) !== null) { if (account.get('id') !== me && account.get('relationship', null) !== null) {
@@ -70,7 +80,7 @@ export default class Account extends ImmutablePureComponent {
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>

View File

@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
export default class Avatar extends React.PureComponent { export default class Avatar extends React.PureComponent {
static propTypes = { static propTypes = {
src: PropTypes.string.isRequired, account: ImmutablePropTypes.map.isRequired,
staticSrc: PropTypes.string,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
style: PropTypes.object, style: PropTypes.object,
animate: PropTypes.bool, animate: PropTypes.bool,
@@ -33,9 +33,12 @@ export default class Avatar extends React.PureComponent {
} }
render () { render () {
const { src, size, staticSrc, animate, inline } = this.props; const { account, size, animate, inline } = this.props;
const { hovering } = this.state; const { hovering } = this.state;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
let className = 'account__avatar'; let className = 'account__avatar';
if (inline) { if (inline) {

View File

@@ -1,22 +1,22 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes';
export default class AvatarOverlay extends React.PureComponent { export default class AvatarOverlay extends React.PureComponent {
static propTypes = { static propTypes = {
staticSrc: PropTypes.string.isRequired, account: ImmutablePropTypes.map.isRequired,
overlaySrc: PropTypes.string.isRequired, friend: ImmutablePropTypes.map.isRequired,
}; };
render() { render() {
const { staticSrc, overlaySrc } = this.props; const { account, friend } = this.props;
const baseStyle = { const baseStyle = {
backgroundImage: `url(${staticSrc})`, backgroundImage: `url(${account.get('avatar_static')})`,
}; };
const overlayStyle = { const overlayStyle = {
backgroundImage: `url(${overlaySrc})`, backgroundImage: `url(${friend.get('avatar_static')})`,
}; };
return ( return (

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import scrollTop from '../scroll'; import { scrollTop } from '../scroll';
export default class Column extends React.PureComponent { export default class Column extends React.PureComponent {
@@ -32,7 +32,7 @@ export default class Column extends React.PureComponent {
} }
componentDidMount () { componentDidMount () {
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents ? { passive: true } : false); this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
} }
componentWillUnmount () { componentWillUnmount () {

View File

@@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import emojify from '../emoji';
export default class DisplayName extends React.PureComponent { export default class DisplayName extends React.PureComponent {
@@ -10,12 +8,11 @@ export default class DisplayName extends React.PureComponent {
}; };
render () { render () {
const displayName = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name'); const displayNameHtml = { __html: this.props.account.get('display_name_html') };
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return ( return (
<span className='display-name'> <span className='display-name'>
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
</span> </span>
); );
} }

View File

@@ -0,0 +1,122 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
export default class IntersectionObserverArticle extends ImmutablePureComponent {
static propTypes = {
intersectionObserverWrapper: PropTypes.object,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
children: PropTypes.node,
};
state = {
isHidden: false, // set to true in requestIdleCallback to trigger un-render
}
shouldComponentUpdate (nextProps, nextState) {
if (!nextState.isIntersecting && nextState.isHidden) {
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
// that either "isIntersecting" or "isHidden" matter, and then they're
// the only things that matter (and updated ARIA attributes).
return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
} else if (nextState.isIntersecting && !this.state.isIntersecting) {
// If we're going from a non-intersecting state to an intersecting state,
// (i.e. offscreen to onscreen), then we definitely need to re-render
return true;
}
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
return super.shouldComponentUpdate(nextProps, nextState);
}
componentDidMount () {
if (!this.props.intersectionObserverWrapper) {
// TODO: enable IntersectionObserver optimization for notification statuses.
// These are managed in notifications/index.js rather than status_list.js
return;
}
this.props.intersectionObserverWrapper.observe(
this.props.id,
this.node,
this.handleIntersection
);
this.componentMounted = true;
}
componentWillUnmount () {
if (this.props.intersectionObserverWrapper) {
this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
}
this.componentMounted = false;
}
handleIntersection = (entry) => {
if (this.node && this.node.children.length !== 0) {
// save the height of the fully-rendered element
this.height = getRectFromEntry(entry).height;
if (this.props.onHeightChange) {
this.props.onHeightChange(this.props.status, this.height);
}
}
this.setState((prevState) => {
if (prevState.isIntersecting && !entry.isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting: entry.isIntersecting,
isHidden: false,
};
});
}
hideIfNotIntersecting = () => {
if (!this.componentMounted) {
return;
}
// When the browser gets a chance, test if we're still not intersecting,
// and if so, set our isHidden to true to trigger an unrender. The point of
// this is to save DOM nodes and avoid using up too much memory.
// See: https://github.com/tootsuite/mastodon/issues/2900
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
}
handleRef = (node) => {
this.node = node;
}
render () {
const { children, id, index, listLength } = this.props;
const { isIntersecting, isHidden } = this.state;
if (!isIntersecting && isHidden) {
return (
<article
ref={this.handleRef}
aria-posinset={index}
aria-setsize={listLength}
style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}
data-id={id}
tabIndex='0'
>
{children && React.cloneElement(children, { hidden: true })}
</article>
);
}
return (
<article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'>
{children && React.cloneElement(children, { hidden: false })}
</article>
);
}
}

View File

@@ -0,0 +1,204 @@
import React, { PureComponent } from 'react';
import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types';
import IntersectionObserverArticle from './intersection_observer_article';
import LoadMore from './load_more';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable';
export default class ScrollableList extends PureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
onScrollToBottom: PropTypes.func,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
children: PropTypes.node,
};
static defaultProps = {
trackScroll: true,
};
state = {
lastMouseMove: null,
};
intersectionObserverWrapper = new IntersectionObserverWrapper();
handleScroll = throttle(() => {
if (this.node) {
const { scrollTop, scrollHeight, clientHeight } = this.node;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
this.props.onScrollToBottom();
} else if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
} else if (this.props.onScroll) {
this.props.onScroll();
}
}
}, 150, {
trailing: true,
});
handleMouseMove = throttle(() => {
this._lastMouseMove = new Date();
}, 300);
handleMouseLeave = () => {
this._lastMouseMove = null;
}
componentDidMount () {
this.attachScrollListener();
this.attachIntersectionObserver();
// Handle initial scroll posiiton
this.handleScroll();
}
componentDidUpdate (prevProps) {
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
// Reset the scroll position when a new child comes in in order not to
// jerk the scrollbar around if you're already scrolled down the page.
if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
if (this.node.scrollTop !== newScrollTop) {
this.node.scrollTop = newScrollTop;
}
} else {
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
}
}
componentWillUnmount () {
this.detachScrollListener();
this.detachIntersectionObserver();
}
attachIntersectionObserver () {
this.intersectionObserverWrapper.connect({
root: this.node,
rootMargin: '300% 0px',
});
}
detachIntersectionObserver () {
this.intersectionObserverWrapper.disconnect();
}
attachScrollListener () {
this.node.addEventListener('scroll', this.handleScroll);
}
detachScrollListener () {
this.node.removeEventListener('scroll', this.handleScroll);
}
getFirstChildKey (props) {
const { children } = props;
let firstChild = children;
if (children instanceof ImmutableList) {
firstChild = children.get(0);
} else if (Array.isArray(children)) {
firstChild = children[0];
}
return firstChild && firstChild.key;
}
setRef = (c) => {
this.node = c;
}
handleLoadMore = (e) => {
e.preventDefault();
this.props.onScrollToBottom();
}
_recentlyMoved () {
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
}
handleKeyDown = (e) => {
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
const article = (() => {
switch (e.key) {
case 'PageDown':
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
case 'PageUp':
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
case 'End':
return this.node.querySelector('[role="feed"] > article:last-of-type');
case 'Home':
return this.node.querySelector('[role="feed"] > article:first-of-type');
default:
return null;
}
})();
if (article) {
e.preventDefault();
article.focus();
article.scrollIntoView();
}
}
}
render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
const childrenCount = React.Children.count(children);
const loadMore = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />;
let scrollableArea = null;
if (isLoading || childrenCount > 0 || !emptyMessage) {
scrollableArea = (
<div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
<div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
{prepend}
{React.Children.map(this.props.children, (child, index) => (
<IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}>
{child}
</IntersectionObserverArticle>
))}
{loadMore}
</div>
</div>
);
} else {
scrollableArea = (
<div className='empty-column-indicator' ref={this.setRef}>
{emptyMessage}
</div>
);
}
if (trackScroll) {
return (
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
{scrollableArea}
</ScrollContainer>
);
} else {
return scrollableArea;
}
}
}

View File

@@ -8,16 +8,12 @@ import DisplayName from './display_name';
import StatusContent from './status_content'; import StatusContent from './status_content';
import StatusActionBar from './status_action_bar'; import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import emojify from '../emoji';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
// We use the component (and not the container) since we do not want // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle'; import Bundle from '../features/ui/components/bundle';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
export default class Status extends ImmutablePureComponent { export default class Status extends ImmutablePureComponent {
@@ -28,27 +24,25 @@ export default class Status extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
wrapped: PropTypes.bool,
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
onPin: PropTypes.func,
onOpenMedia: PropTypes.func, onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func, onOpenVideo: PropTypes.func,
onBlock: PropTypes.func, onBlock: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
me: PropTypes.number, me: PropTypes.number,
boostModal: PropTypes.bool, boostModal: PropTypes.bool,
autoPlayGif: PropTypes.bool, autoPlayGif: PropTypes.bool,
muted: PropTypes.bool, muted: PropTypes.bool,
intersectionObserverWrapper: PropTypes.object, hidden: PropTypes.bool,
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}; };
state = { state = {
isExpanded: false, isExpanded: false,
isIntersecting: true, // assume intersecting until told otherwise
isHidden: false, // set to true in requestIdleCallback to trigger un-render
} }
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@@ -56,87 +50,15 @@ export default class Status extends ImmutablePureComponent {
updateOnProps = [ updateOnProps = [
'status', 'status',
'account', 'account',
'wrapped',
'me', 'me',
'boostModal', 'boostModal',
'autoPlayGif', 'autoPlayGif',
'muted', 'muted',
'listLength', 'hidden',
] ]
updateOnStates = ['isExpanded'] updateOnStates = ['isExpanded']
shouldComponentUpdate (nextProps, nextState) {
if (!nextState.isIntersecting && nextState.isHidden) {
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true
// that either "isIntersecting" or "isHidden" matter, and then they're
// the only things that matter (and updated ARIA attributes).
return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
} else if (nextState.isIntersecting && !this.state.isIntersecting) {
// If we're going from a non-intersecting state to an intersecting state,
// (i.e. offscreen to onscreen), then we definitely need to re-render
return true;
}
// Otherwise, diff based on "updateOnProps" and "updateOnStates"
return super.shouldComponentUpdate(nextProps, nextState);
}
componentDidMount () {
if (!this.props.intersectionObserverWrapper) {
// TODO: enable IntersectionObserver optimization for notification statuses.
// These are managed in notifications/index.js rather than status_list.js
return;
}
this.props.intersectionObserverWrapper.observe(
this.props.id,
this.node,
this.handleIntersection
);
this.componentMounted = true;
}
componentWillUnmount () {
if (this.props.intersectionObserverWrapper) {
this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
}
this.componentMounted = false;
}
handleIntersection = (entry) => {
if (this.node && this.node.children.length !== 0) {
// save the height of the fully-rendered element
this.height = getRectFromEntry(entry).height;
}
this.setState((prevState) => {
if (prevState.isIntersecting && !entry.isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting: entry.isIntersecting,
isHidden: false,
};
});
}
hideIfNotIntersecting = () => {
if (!this.componentMounted) {
return;
}
// When the browser gets a chance, test if we're still not intersecting,
// and if so, set our isHidden to true to trigger an unrender. The point of
// this is to save DOM nodes and avoid using up too much memory.
// See: https://github.com/tootsuite/mastodon/issues/2900
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
}
handleRef = (node) => {
this.node = node;
}
handleClick = () => { handleClick = () => {
if (!this.context.router) { if (!this.context.router) {
return; return;
@@ -170,42 +92,34 @@ export default class Status extends ImmutablePureComponent {
let media = null; let media = null;
let statusAvatar; let statusAvatar;
// Exclude intersectionObserverWrapper from `other` variable const { status, account, hidden, ...other } = this.props;
// because intersection is managed in here. const { isExpanded } = this.state;
const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props;
const { isExpanded, isIntersecting, isHidden } = this.state;
if (status === null) { if (status === null) {
return null; return null;
} }
if (!isIntersecting && isHidden) { if (hidden) {
return ( return (
<article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> <div>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
{status.get('content')} {status.get('content')}
</article> </div>
); );
} }
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
let displayName = status.getIn(['account', 'display_name']); const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
if (displayName.length === 0) {
displayName = status.getIn(['account', 'username']);
}
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return ( return (
<article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'> <div className='status__wrapper' data-id={status.get('id')} >
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} /> <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
</div> </div>
<Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} /> <Status {...other} status={status.get('reblog')} account={status.get('account')} />
</article> </div>
); );
} }
@@ -228,13 +142,13 @@ export default class Status extends ImmutablePureComponent {
} }
if (account === undefined || account === null) { if (account === undefined || account === null) {
statusAvatar = <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />; statusAvatar = <Avatar account={status.get('account')} size={48} />;
}else{ }else{
statusAvatar = <AvatarOverlay staticSrc={status.getIn(['account', 'avatar_static'])} overlaySrc={account.get('avatar_static')} />; statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
} }
return ( return (
<article aria-posinset={index} aria-setsize={listLength} className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} tabIndex={wrapped ? null : '0'} ref={this.handleRef}> <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
<div className='status__info'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
@@ -252,7 +166,7 @@ export default class Status extends ImmutablePureComponent {
{media} {media}
<StatusActionBar {...this.props} /> <StatusActionBar {...this.props} />
</article> </div>
); );
} }

View File

@@ -21,6 +21,9 @@ const messages = defineMessages({
report: { id: 'status.report', defaultMessage: 'Report @{name}' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' },
}); });
@injectIntl @injectIntl
@@ -40,7 +43,9 @@ export default class StatusActionBar extends ImmutablePureComponent {
onMute: PropTypes.func, onMute: PropTypes.func,
onBlock: PropTypes.func, onBlock: PropTypes.func,
onReport: PropTypes.func, onReport: PropTypes.func,
onEmbed: PropTypes.func,
onMuteConversation: PropTypes.func, onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
me: PropTypes.number, me: PropTypes.number,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@@ -77,6 +82,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
this.props.onDelete(this.props.status); this.props.onDelete(this.props.status);
} }
handlePinClick = () => {
this.props.onPin(this.props.status);
}
handleMentionClick = () => { handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history); this.props.onMention(this.props.status.get('account'), this.context.router.history);
} }
@@ -93,6 +102,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
} }
handleEmbed = () => {
this.props.onEmbed(this.props.status);
}
handleReport = () => { handleReport = () => {
this.props.onReport(this.props.status); this.props.onReport(this.props.status);
} }
@@ -103,9 +116,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
render () { render () {
const { status, me, intl, withDismiss } = this.props; const { status, me, intl, withDismiss } = this.props;
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
const anonymousAccess = !me; const anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
let menu = []; let menu = [];
let reblogIcon = 'retweet'; let reblogIcon = 'retweet';
@@ -113,14 +127,23 @@ export default class StatusActionBar extends ImmutablePureComponent {
let replyTitle; let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
menu.push(null); menu.push(null);
if (withDismiss) { if (status.getIn(['account', 'id']) === me || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); menu.push(null);
} }
if (status.getIn(['account', 'id']) === me) { if (status.getIn(['account', 'id']) === me) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
@@ -151,7 +174,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
<IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} pressed={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' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton} {shareButton}

View File

@@ -1,8 +1,6 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import emojify from '../emoji';
import { isRtl } from '../rtl'; import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from './permalink'; import Permalink from './permalink';
@@ -119,8 +117,8 @@ export default class StatusContent extends React.PureComponent {
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const content = { __html: emojify(status.get('content')) }; const content = { __html: status.get('contentHtml') };
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; const spoilerContent = { __html: status.get('spoilerHtml') };
const directionStyle = { direction: 'ltr' }; const directionStyle = { direction: 'ltr' };
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.context.router, 'status__content--with-action': this.props.onClick && this.context.router,

View File

@@ -1,12 +1,9 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container'; import StatusContainer from '../containers/status_container';
import LoadMore from './load_more';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import ScrollableList from './scrollable_list';
import { throttle } from 'lodash';
export default class StatusList extends ImmutablePureComponent { export default class StatusList extends ImmutablePureComponent {
@@ -28,145 +25,21 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: true, trackScroll: true,
}; };
intersectionObserverWrapper = new IntersectionObserverWrapper();
handleScroll = throttle(() => {
if (this.node) {
const { scrollTop, scrollHeight, clientHeight } = this.node;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
this.props.onScrollToBottom();
} else if (scrollTop < 100 && this.props.onScrollToTop) {
this.props.onScrollToTop();
} else if (this.props.onScroll) {
this.props.onScroll();
}
}
}, 150, {
trailing: true,
});
componentDidMount () {
this.attachScrollListener();
this.attachIntersectionObserver();
// Handle initial scroll posiiton
this.handleScroll();
}
componentDidUpdate (prevProps) {
// Reset the scroll position when a new toot comes in in order not to
// jerk the scrollbar around if you're already scrolled down the page.
if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) {
if (prevProps.statusIds.first() !== this.props.statusIds.first()) {
let newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
if (this.node.scrollTop !== newScrollTop) {
this.node.scrollTop = newScrollTop;
}
} else {
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
}
}
}
componentWillUnmount () {
this.detachScrollListener();
this.detachIntersectionObserver();
}
attachIntersectionObserver () {
this.intersectionObserverWrapper.connect({
root: this.node,
rootMargin: '300% 0px',
});
}
detachIntersectionObserver () {
this.intersectionObserverWrapper.disconnect();
}
attachScrollListener () {
this.node.addEventListener('scroll', this.handleScroll);
}
detachScrollListener () {
this.node.removeEventListener('scroll', this.handleScroll);
}
setRef = (c) => {
this.node = c;
}
handleLoadMore = (e) => {
e.preventDefault();
this.props.onScrollToBottom();
}
handleKeyDown = (e) => {
if (['PageDown', 'PageUp', 'End', 'Home'].includes(e.key)) {
const article = (() => {
switch (e.key) {
case 'PageDown':
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
case 'PageUp':
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
case 'End':
return this.node.querySelector('[role="feed"] > article:last-of-type');
case 'Home':
return this.node.querySelector('[role="feed"] > article:first-of-type');
default:
return null;
}
})();
if (article) {
e.preventDefault();
article.focus();
article.scrollIntoView();
}
}
}
render () { render () {
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; const { statusIds, ...other } = this.props;
const { isLoading } = other;
const loadMore = <LoadMore visible={!isLoading && statusIds.size > 0 && hasMore} onClick={this.handleLoadMore} />; const scrollableContent = (isLoading || statusIds.size > 0) ? (
let scrollableArea = null; statusIds.map((statusId) => (
<StatusContainer key={statusId} id={statusId} />
))
) : null;
if (isLoading || statusIds.size > 0 || !emptyMessage) { return (
scrollableArea = ( <ScrollableList {...other}>
<div className='scrollable' ref={this.setRef}> {scrollableContent}
<div role='feed' className='status-list' onKeyDown={this.handleKeyDown}> </ScrollableList>
{prepend} );
{statusIds.map((statusId, index) => {
return <StatusContainer key={statusId} id={statusId} index={index} listLength={statusIds.size} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
})}
{loadMore}
</div>
</div>
);
} else {
scrollableArea = (
<div className='empty-column-indicator' ref={this.setRef}>
{emptyMessage}
</div>
);
}
if (trackScroll) {
return (
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
{scrollableArea}
</ScrollContainer>
);
} else {
return scrollableArea;
}
} }
} }

View File

@@ -146,29 +146,29 @@ export default class VideoPlayer extends React.PureComponent {
if (!this.state.visible) { if (!this.state.visible) {
if (sensitive) { if (sensitive) {
return ( return (
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton} {spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> <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> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div> </button>
); );
} else { } else {
return ( return (
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton} {spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> <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> <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div> </button>
); );
} }
} }
if (this.state.preview && !autoplay) { if (this.state.preview && !autoplay) {
return ( return (
<div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> <button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
{spoilerButton} {spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</div> </button>
); );
} }

View File

@@ -32,7 +32,7 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) { onFollow (account) {
if (account.getIn(['relationship', 'following'])) { if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
if (this.unfollowModal) { if (this.unfollowModal) {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import configureStore from '../store/configureStore';
import { hydrateStore } from '../actions/store';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import Compose from '../features/standalone/compose';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const store = configureStore();
const initialStateContainer = document.getElementById('initial-state');
if (initialStateContainer !== null) {
const initialState = JSON.parse(initialStateContainer.textContent);
store.dispatch(hydrateStore(initialState));
}
export default class TimelineContainer extends React.PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
};
render () {
const { locale } = this.props;
return (
<IntlProvider locale={locale} messages={messages}>
<Provider store={store}>
<Compose />
</Provider>
</IntlProvider>
);
}
}

View File

@@ -2,21 +2,13 @@ import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import configureStore from '../store/configureStore'; import configureStore from '../store/configureStore';
import {
updateTimeline,
deleteFromTimelines,
refreshHomeTimeline,
connectTimeline,
disconnectTimeline,
} from '../actions/timelines';
import { showOnboardingOnce } from '../actions/onboarding'; import { showOnboardingOnce } from '../actions/onboarding';
import { updateNotifications, refreshNotifications } from '../actions/notifications';
import BrowserRouter from 'react-router-dom/BrowserRouter'; import BrowserRouter from 'react-router-dom/BrowserRouter';
import Route from 'react-router-dom/Route'; import Route from 'react-router-dom/Route';
import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext';
import UI from '../features/ui'; import UI from '../features/ui';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from '../actions/store';
import createStream from '../stream'; import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
@@ -33,74 +25,28 @@ export default class Mastodon extends React.PureComponent {
}; };
componentDidMount() { componentDidMount() {
const { locale } = this.props; this.disconnect = store.dispatch(connectUserStream());
const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = store.getState().getIn(['meta', 'access_token']);
const setupPolling = () => {
this.polling = setInterval(() => {
store.dispatch(refreshHomeTimeline());
store.dispatch(refreshNotifications());
}, 20000);
};
const clearPolling = () => {
clearInterval(this.polling);
this.polling = undefined;
};
this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', {
connected () {
clearPolling();
store.dispatch(connectTimeline('home'));
},
disconnected () {
setupPolling();
store.dispatch(disconnectTimeline('home'));
},
received (data) {
switch(data.event) {
case 'update':
store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
break;
case 'delete':
store.dispatch(deleteFromTimelines(data.payload));
break;
case 'notification':
store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;
}
},
reconnected () {
clearPolling();
store.dispatch(connectTimeline('home'));
store.dispatch(refreshHomeTimeline());
store.dispatch(refreshNotifications());
},
});
// Desktop notifications // Desktop notifications
// Ask after 1 minute
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission(); window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
}
// Protocol handler
// Ask after 5 minutes
if (typeof navigator.registerProtocolHandler !== 'undefined') {
const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
} }
store.dispatch(showOnboardingOnce()); store.dispatch(showOnboardingOnce());
} }
componentWillUnmount () { componentWillUnmount () {
if (typeof this.subscription !== 'undefined') { if (this.disconnect) {
this.subscription.close(); this.disconnect();
this.subscription = null; this.disconnect = null;
}
if (typeof this.polling !== 'undefined') {
clearInterval(this.polling);
this.polling = null;
} }
} }

View File

@@ -11,12 +11,14 @@ import {
favourite, favourite,
unreblog, unreblog,
unfavourite, unfavourite,
pin,
unpin,
} from '../actions/interactions'; } from '../actions/interactions';
import { import {
blockAccount, blockAccount,
muteAccount, muteAccount,
} from '../actions/accounts'; } from '../actions/accounts';
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal'; import { openModal } from '../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -72,6 +74,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));
} else {
dispatch(pin(status));
}
},
onEmbed (status) {
dispatch(openModal('EMBED', { url: status.get('url') }));
},
onDelete (status) { onDelete (status) {
if (!this.deleteModal) { if (!this.deleteModal) {
dispatch(deleteStatus(status.get('id'))); dispatch(deleteStatus(status.get('id')));
@@ -124,6 +138,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onHeightChange (status, height) {
dispatch(setStatusHeight(status.get('id'), height));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View File

@@ -3,34 +3,28 @@ import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping)); const trie = new Trie(Object.keys(unicodeMapping));
const excluded = ['™', '©', '®']; const emojify = str => {
let rtn = '';
function emojify(str) { for (;;) {
// This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.) let match, i = 0;
// and replacing valid unicode strings while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) {
// that _aren't_ within tags with an <img> version. i += str.codePointAt(i) < 65536 ? 1 : 2;
// The goal is to be the same as an emojione.regUnicode replacement, but faster. }
let i = -1; if (i === str.length)
let insideTag = false; break;
let match; else if (str[i] === '<') {
while (++i < str.length) { let tagend = str.indexOf('>', i + 1) + 1;
const char = str.charAt(i); if (!tagend)
if (insideTag && char === '>') { break;
insideTag = false; rtn += str.slice(0, tagend);
} else if (char === '<') { str = str.slice(tagend);
insideTag = true; } else {
} else if (!insideTag && (match = trie.search(str.substring(i)))) { const [filename, shortCode] = unicodeMapping[match];
const unicodeStr = match; rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
if (unicodeStr in unicodeMapping && excluded.indexOf(unicodeStr) === -1) { str = str.slice(i + match.length);
const [filename, shortCode] = unicodeMapping[unicodeStr];
const alt = unicodeStr;
const replacement = `<img draggable="false" class="emojione" alt="${alt}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
}
} }
} }
return str; return rtn + str;
} };
export default emojify; export default emojify;

View File

@@ -4,8 +4,10 @@
const emojione = require('emojione'); const emojione = require('emojione');
const mappedUnicode = emojione.mapUnicodeToShort(); const mappedUnicode = emojione.mapUnicodeToShort();
const excluded = ['®', '©', '™'];
module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap) module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap)
.filter(c => !excluded.includes(c))
.map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]]) .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
.map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] })) .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] }))
.reduce((x, y) => Object.assign(x, y), { }); .reduce((x, y) => Object.assign(x, y), { });

View File

@@ -1,8 +1,6 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import Motion from 'react-motion/lib/Motion'; import Motion from 'react-motion/lib/Motion';
@@ -13,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
}); });
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
@@ -92,15 +90,10 @@ export default class Header extends ImmutablePureComponent {
return null; return null;
} }
let displayName = account.get('display_name');
let info = ''; let info = '';
let actionBtn = ''; let actionBtn = '';
let lockedIcon = ''; let lockedIcon = '';
if (displayName.length === 0) {
displayName = account.get('username');
}
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { 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>; info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
} }
@@ -109,7 +102,7 @@ export default class Header extends ImmutablePureComponent {
if (account.getIn(['relationship', 'requested'])) { if (account.getIn(['relationship', 'requested'])) {
actionBtn = ( actionBtn = (
<div className='account--action-button'> <div className='account--action-button'>
<IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} /> <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />
</div> </div>
); );
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
@@ -125,15 +118,15 @@ export default class Header extends ImmutablePureComponent {
lockedIcon = <i className='fa fa-lock' />; lockedIcon = <i className='fa fa-lock' />;
} }
const content = { __html: emojify(account.get('note')) }; const content = { __html: account.get('note_emojified') };
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; const displayNameHtml = { __html: account.get('display_name_html') };
return ( return (
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div> <div>
<Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> <Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
<div className='account__header__content' dangerouslySetInnerHTML={content} /> <div className='account__header__content' dangerouslySetInnerHTML={content} />

View File

@@ -38,7 +38,7 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) { onFollow (account) {
if (account.getIn(['relationship', 'following'])) { if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
if (this.unfollowModal) { if (this.unfollowModal) {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,

View File

@@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header';
import { import {
refreshCommunityTimeline, refreshCommunityTimeline,
expandCommunityTimeline, expandCommunityTimeline,
updateTimeline,
deleteFromTimelines,
connectTimeline,
disconnectTimeline,
} from '../../actions/timelines'; } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import createStream from '../../stream'; import { connectCommunityStream } from '../../actions/streaming';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' }, title: { id: 'column.community', defaultMessage: 'Local timeline' },
@@ -23,8 +19,6 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool, hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
@@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent {
} }
componentDidMount () { componentDidMount () {
const { dispatch, streamingAPIBaseURL, accessToken } = this.props; const { dispatch } = this.props;
dispatch(refreshCommunityTimeline()); dispatch(refreshCommunityTimeline());
this.disconnect = dispatch(connectCommunityStream());
if (typeof this._subscription !== 'undefined') {
return;
}
this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
connected () {
dispatch(connectTimeline('community'));
},
reconnected () {
dispatch(connectTimeline('community'));
},
disconnected () {
dispatch(disconnectTimeline('community'));
},
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline('community', JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
},
});
} }
componentWillUnmount () { componentWillUnmount () {
if (typeof this._subscription !== 'undefined') { if (this.disconnect) {
this._subscription.close(); this.disconnect();
this._subscription = null; this.disconnect = null;
} }
} }

View File

@@ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
return ( return (
<div className='autosuggest-account'> <div className='autosuggest-account'>
<div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div> <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
<DisplayName account={account} /> <DisplayName account={account} />
</div> </div>
); );

View File

@@ -19,7 +19,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar'> <div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar src={this.props.account.get('avatar')} animate size={40} /> <Avatar account={this.props.account} size={40} />
</Permalink> </Permalink>
<div className='navigation-bar__profile'> <div className='navigation-bar__profile'>

View File

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import Avatar from '../../../components/avatar'; import Avatar from '../../../components/avatar';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import DisplayName from '../../../components/display_name'; import DisplayName from '../../../components/display_name';
import emojify from '../../../emoji';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -43,7 +42,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
return null; return null;
} }
const content = { __html: emojify(status.get('content')) }; const content = { __html: status.get('contentHtml') };
return ( return (
<div className='reply-indicator'> <div className='reply-indicator'>
@@ -51,7 +50,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div> <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</a> </a>
</div> </div>

View File

@@ -16,6 +16,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']), statusIds: state.getIn(['status_lists', 'favourites', 'items']),
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
}; };
componentWillMount () { componentWillMount () {
@@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent {
} }
render () { render () {
const { intl, statusIds, columnId, multiColumn } = this.props; const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
return ( return (
@@ -75,12 +77,14 @@ export default class Favourites extends ImmutablePureComponent {
onClick={this.handleHeaderClick} onClick={this.handleHeaderClick}
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
showBackButton
/> />
<StatusList <StatusList
trackScroll={!pinned} trackScroll={!pinned}
statusIds={statusIds} statusIds={statusIds}
scrollKey={`favourited_statuses-${columnId}`} scrollKey={`favourited_statuses-${columnId}`}
hasMore={hasMore}
onScrollToBottom={this.handleScrollToBottom} onScrollToBottom={this.handleScrollToBottom}
/> />
</Column> </Column>

View File

@@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from '../../../components/permalink'; import Permalink from '../../../components/permalink';
import Avatar from '../../../components/avatar'; import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name'; import DisplayName from '../../../components/display_name';
import emojify from '../../../emoji';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -26,13 +25,13 @@ export default class AccountAuthorize extends ImmutablePureComponent {
render () { render () {
const { intl, account, onAuthorize, onReject } = this.props; const { intl, account, onAuthorize, onReject } = this.props;
const content = { __html: emojify(account.get('note')) }; const content = { __html: account.get('note_emojified') };
return ( return (
<div className='account-authorize__wrapper'> <div className='account-authorize__wrapper'>
<div className='account-authorize'> <div className='account-authorize'>
<Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'> <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
<div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div> <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>

View File

@@ -23,6 +23,7 @@ const messages = defineMessages({
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
@@ -66,15 +67,16 @@ export default class GettingStarted extends ImmutablePureComponent {
navItems = navItems.concat([ navItems = navItems.concat([
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]); ]);
if (me.get('locked')) { if (me.get('locked')) {
navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
} }
navItems = navItems.concat([ navItems = navItems.concat([
<ColumnLink key='6' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='7' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
]); ]);
return ( return (

View File

@@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header';
import { import {
refreshHashtagTimeline, refreshHashtagTimeline,
expandHashtagTimeline, expandHashtagTimeline,
updateTimeline,
deleteFromTimelines,
} from '../../actions/timelines'; } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import createStream from '../../stream'; import { connectHashtagStream } from '../../actions/streaming';
const mapStateToProps = state => ({ const mapStateToProps = (state, props) => ({
hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool, hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
@@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent {
} }
_subscribe (dispatch, id) { _subscribe (dispatch, id) {
const { streamingAPIBaseURL, accessToken } = this.props; this.disconnect = dispatch(connectHashtagStream(id));
this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
},
});
} }
_unsubscribe () { _unsubscribe () {
if (typeof this.subscription !== 'undefined') { if (this.disconnect) {
this.subscription.close(); this.disconnect();
this.subscription = null; this.disconnect = null;
} }
} }

View File

@@ -1,17 +1,17 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink'; import Permalink from '../../../components/permalink';
import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
export default class Notification extends ImmutablePureComponent { export default class Notification extends ImmutablePureComponent {
static propTypes = { static propTypes = {
notification: ImmutablePropTypes.map.isRequired, notification: ImmutablePropTypes.map.isRequired,
hidden: PropTypes.bool,
}; };
renderFollow (account, link) { renderFollow (account, link) {
@@ -25,13 +25,13 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
</div> </div>
<AccountContainer id={account.get('id')} withNote={false} /> <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
</div> </div>
); );
} }
renderMention (notification) { renderMention (notification) {
return <StatusContainer id={notification.get('status')} withDismiss />; return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
} }
renderFavourite (notification, link) { renderFavourite (notification, link) {
@@ -44,7 +44,7 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
</div> </div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
</div> </div>
); );
} }
@@ -59,7 +59,7 @@ export default class Notification extends ImmutablePureComponent {
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
</div> </div>
<StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
</div> </div>
); );
} }
@@ -67,9 +67,8 @@ export default class Notification extends ImmutablePureComponent {
render () { render () {
const { notification } = this.props; const { notification } = this.props;
const account = notification.get('account'); const account = notification.get('account');
const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); const displayNameHtml = { __html: account.get('display_name_html') };
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} />;
const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
switch(notification.get('type')) { switch(notification.get('type')) {
case 'follow': case 'follow':

View File

@@ -18,12 +18,6 @@ export default class SettingToggle extends React.PureComponent {
this.props.onChange(this.props.settingKey, target.checked); this.props.onChange(this.props.settingKey, target.checked);
} }
onKeyDown = e => {
if (e.key === ' ') {
this.props.onChange(this.props.settingKey, !e.target.checked);
}
}
render () { render () {
const { prefix, settings, settingKey, label, meta } = this.props; const { prefix, settings, settingKey, label, meta } = this.props;
const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');

View File

@@ -7,13 +7,12 @@ import ColumnHeader from '../../components/column_header';
import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; import { expandNotifications, scrollTopNotifications } from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container'; import NotificationContainer from './containers/notification_container';
import { ScrollContainer } from 'react-router-scroll';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import LoadMore from '../../components/load_more';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' }, title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -51,40 +50,18 @@ export default class Notifications extends React.PureComponent {
trackScroll: true, trackScroll: true,
}; };
dispatchExpandNotifications = debounce(() => { handleScrollToBottom = debounce(() => {
this.props.dispatch(scrollTopNotifications(false));
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
}, 300, { leading: true }); }, 300, { leading: true });
dispatchScrollToTop = debounce((top) => { handleScrollToTop = debounce(() => {
this.props.dispatch(scrollTopNotifications(top)); this.props.dispatch(scrollTopNotifications(true));
}, 100); }, 100);
handleScroll = (e) => { handleScroll = debounce(() => {
const { scrollTop, scrollHeight, clientHeight } = e.target; this.props.dispatch(scrollTopNotifications(false));
const offset = scrollHeight - scrollTop - clientHeight; }, 100);
this._oldScrollPosition = scrollHeight - scrollTop;
if (250 > offset && this.props.hasMore && !this.props.isLoading) {
this.dispatchExpandNotifications();
}
if (scrollTop < 100) {
this.dispatchScrollToTop(true);
} else {
this.dispatchScrollToTop(false);
}
}
componentDidUpdate (prevProps) {
if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) {
this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
}
}
handleLoadMore = (e) => {
e.preventDefault();
this.dispatchExpandNotifications();
}
handlePin = () => { handlePin = () => {
const { columnId, dispatch } = this.props; const { columnId, dispatch } = this.props;
@@ -105,10 +82,6 @@ export default class Notifications extends React.PureComponent {
this.column.scrollTop(); this.column.scrollTop();
} }
setRef = (c) => {
this.node = c;
}
setColumnRef = c => { setColumnRef = c => {
this.column = c; this.column = c;
} }
@@ -116,52 +89,35 @@ export default class Notifications extends React.PureComponent {
render () { render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
let loadMore = ''; let scrollableContent = null;
let scrollableArea = '';
let unread = '';
let scrollContainer = '';
if (!isLoading && hasMore) { if (isLoading && this.scrollableContent) {
loadMore = <LoadMore onClick={this.handleLoadMore} />; scrollableContent = this.scrollableContent;
}
if (isUnread) {
unread = <div className='notifications__unread-indicator' />;
}
if (isLoading && this.scrollableArea) {
scrollableArea = this.scrollableArea;
} else if (notifications.size > 0 || hasMore) { } else if (notifications.size > 0 || hasMore) {
scrollableArea = ( scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
<div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
{unread}
<div>
{notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
{loadMore}
</div>
</div>
);
} else { } else {
scrollableArea = ( scrollableContent = null;
<div className='empty-column-indicator' ref={this.setRef}>
<FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
</div>
);
} }
if (pinned) { this.scrollableContent = scrollableContent;
scrollContainer = scrollableArea;
} else {
scrollContainer = (
<ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}>
{scrollableArea}
</ScrollContainer>
);
}
this.scrollableArea = scrollableArea; const scrollContainer = (
<ScrollableList
scrollKey={`notifications-${columnId}`}
trackScroll={!pinned}
isLoading={isLoading}
hasMore={hasMore}
emptyMessage={emptyMessage}
onScrollToBottom={this.handleScrollToBottom}
onScrollToTop={this.handleScrollToTop}
onScroll={this.handleScroll}
shouldUpdateScroll={shouldUpdateScroll}
>
{scrollableContent}
</ScrollableList>
);
return ( return (
<Column ref={this.setColumnRef}> <Column ref={this.setColumnRef}>

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import StatusList from '../../components/status_list';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
heading: { id: 'column.pins', defaultMessage: 'Pinned toot' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'pins', 'items']),
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
});
@connect(mapStateToProps)
@injectIntl
export default class PinnedStatuses extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
intl: PropTypes.object.isRequired,
hasMore: PropTypes.bool.isRequired,
};
componentWillMount () {
this.props.dispatch(fetchPinnedStatuses());
}
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
render () {
const { intl, statusIds, hasMore } = this.props;
return (
<Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
<ColumnBackButtonSlim />
<StatusList
statusIds={statusIds}
scrollKey='pinned_statuses'
hasMore={hasMore}
/>
</Column>
);
}
}

View File

@@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header';
import { import {
refreshPublicTimeline, refreshPublicTimeline,
expandPublicTimeline, expandPublicTimeline,
updateTimeline,
deleteFromTimelines,
connectTimeline,
disconnectTimeline,
} from '../../actions/timelines'; } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import createStream from '../../stream'; import { connectPublicStream } from '../../actions/streaming';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.public', defaultMessage: 'Federated timeline' }, title: { id: 'column.public', defaultMessage: 'Federated timeline' },
@@ -23,8 +19,6 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
columnId: PropTypes.string, columnId: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool, hasUnread: PropTypes.bool,
}; };
@@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent {
} }
componentDidMount () { componentDidMount () {
const { dispatch, streamingAPIBaseURL, accessToken } = this.props; const { dispatch } = this.props;
dispatch(refreshPublicTimeline()); dispatch(refreshPublicTimeline());
this.disconnect = dispatch(connectPublicStream());
if (typeof this._subscription !== 'undefined') {
return;
}
this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
connected () {
dispatch(connectTimeline('public'));
},
reconnected () {
dispatch(connectTimeline('public'));
},
disconnected () {
dispatch(disconnectTimeline('public'));
},
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline('public', JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
},
});
} }
componentWillUnmount () { componentWillUnmount () {
if (typeof this._subscription !== 'undefined') { if (this.disconnect) {
this._subscription.close(); this.disconnect();
this._subscription = null; this.disconnect = null;
} }
} }

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import emojify from '../../../emoji';
import Toggle from 'react-toggle'; import Toggle from 'react-toggle';
export default class StatusCheckBox extends React.PureComponent { export default class StatusCheckBox extends React.PureComponent {
@@ -15,7 +14,7 @@ export default class StatusCheckBox extends React.PureComponent {
render () { render () {
const { status, checked, onToggle, disabled } = this.props; const { status, checked, onToggle, disabled } = this.props;
const content = { __html: emojify(status.get('content')) }; const content = { __html: status.get('contentHtml') };
if (status.get('reblog')) { if (status.get('reblog')) {
return null; return null;

View File

@@ -0,0 +1,18 @@
import React from 'react';
import ComposeFormContainer from '../../compose/containers/compose_form_container';
import NotificationsContainer from '../../ui/containers/notifications_container';
import LoadingBarContainer from '../../ui/containers/loading_bar_container';
export default class Compose extends React.PureComponent {
render () {
return (
<div>
<ComposeFormContainer />
<NotificationsContainer />
<LoadingBarContainer className='loading-bar' />
</div>
);
}
}

View File

@@ -14,6 +14,9 @@ const messages = defineMessages({
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' },
share: { id: 'status.share', defaultMessage: 'Share' }, share: { id: 'status.share', defaultMessage: 'Share' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' },
}); });
@injectIntl @injectIntl
@@ -31,6 +34,8 @@ export default class ActionBar extends React.PureComponent {
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,
onReport: PropTypes.func, onReport: PropTypes.func,
onPin: PropTypes.func,
onEmbed: PropTypes.func,
me: PropTypes.number.isRequired, me: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@@ -59,6 +64,10 @@ export default class ActionBar extends React.PureComponent {
this.props.onReport(this.props.status); this.props.onReport(this.props.status);
} }
handlePinClick = () => {
this.props.onPin(this.props.status);
}
handleShare = () => { handleShare = () => {
navigator.share({ navigator.share({
text: this.props.status.get('search_index'), text: this.props.status.get('search_index'),
@@ -66,12 +75,26 @@ export default class ActionBar extends React.PureComponent {
}); });
} }
handleEmbed = () => {
this.props.onEmbed(this.props.status);
}
render () { render () {
const { status, me, intl } = this.props; const { status, me, intl } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
let menu = []; let menu = [];
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
if (me === status.getIn(['account', 'id'])) { if (me === status.getIn(['account', 'id'])) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import punycode from 'punycode'; import punycode from 'punycode';
import classnames from 'classnames';
const IDNA_PREFIX = 'xn--'; const IDNA_PREFIX = 'xn--';
@@ -32,7 +33,7 @@ export default class Card extends React.PureComponent {
if (card.get('image')) { if (card.get('image')) {
image = ( image = (
<div className='status-card__image'> <div className='status-card__image'>
<img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' /> <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} />
</div> </div>
); );
} }
@@ -41,8 +42,12 @@ export default class Card extends React.PureComponent {
provider = decodeIDNA(getHostname(card.get('url'))); provider = decodeIDNA(getHostname(card.get('url')));
} }
const className = classnames('status-card', {
'horizontal': card.get('width') > card.get('height'),
});
return ( return (
<a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> <a href={card.get('url')} className={className} target='_blank' rel='noopener'>
{image} {image}
<div className='status-card__content'> <div className='status-card__content'>

View File

@@ -59,7 +59,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
return ( return (
<div className='detailed-status'> <div className='detailed-status'>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div> <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</a> </a>

View File

@@ -12,6 +12,8 @@ import {
unfavourite, unfavourite,
reblog, reblog,
unreblog, unreblog,
pin,
unpin,
} from '../../actions/interactions'; } from '../../actions/interactions';
import { import {
replyCompose, replyCompose,
@@ -87,6 +89,14 @@ export default class Status extends ImmutablePureComponent {
} }
} }
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
} else {
this.props.dispatch(pin(status));
}
}
handleReplyClick = (status) => { handleReplyClick = (status) => {
this.props.dispatch(replyCompose(status, this.context.router.history)); this.props.dispatch(replyCompose(status, this.context.router.history));
} }
@@ -137,6 +147,10 @@ export default class Status extends ImmutablePureComponent {
this.props.dispatch(initReport(status.get('account'), status)); this.props.dispatch(initReport(status.get('account'), status));
} }
handleEmbed = (status) => {
this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
}
renderChildren (list) { renderChildren (list) {
return list.map(id => <StatusContainer key={id} id={id} />); return list.map(id => <StatusContainer key={id} id={id} />);
} }
@@ -187,6 +201,8 @@ export default class Status extends ImmutablePureComponent {
onDelete={this.handleDeleteClick} onDelete={this.handleDeleteClick}
onMention={this.handleMentionClick} onMention={this.handleMentionClick}
onReport={this.handleReport} onReport={this.handleReport}
onPin={this.handlePin}
onEmbed={this.handleEmbed}
/> />
{descendants} {descendants}

View File

@@ -46,7 +46,7 @@ export default class ActionsModal extends ImmutablePureComponent {
<a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'> <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'>
<div className='status__avatar'> <div className='status__avatar'>
<Avatar src={this.props.status.getIn(['account', 'avatar'])} staticSrc={this.props.status.getIn(['account', 'avatar_static'])} size={48} /> <Avatar account={this.props.status.get('account')} size={48} />
</div> </div>
<DisplayName account={this.props.status.get('account')} /> <DisplayName account={this.props.status.get('account')} />

View File

@@ -62,7 +62,7 @@ export default class BoostModal extends ImmutablePureComponent {
<a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
<div className='status__avatar'> <div className='status__avatar'>
<Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /> <Avatar account={status.get('account')} size={48} />
</div> </div>
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ColumnHeader from './column_header'; import ColumnHeader from './column_header';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import scrollTop from '../../../scroll'; import { scrollTop } from '../../../scroll';
import { isMobile } from '../../../is_mobile'; import { isMobile } from '../../../is_mobile';
export default class Column extends React.PureComponent { export default class Column extends React.PureComponent {
@@ -25,6 +25,17 @@ export default class Column extends React.PureComponent {
this._interruptScrollAnimation = scrollTop(scrollable); this._interruptScrollAnimation = scrollTop(scrollable);
} }
scrollTop () {
const scrollable = this.node.querySelector('.scrollable');
if (!scrollable) {
return;
}
this._interruptScrollAnimation = scrollTop(scrollable);
}
handleScroll = debounce(() => { handleScroll = debounce(() => {
if (typeof this._interruptScrollAnimation !== 'undefined') { if (typeof this._interruptScrollAnimation !== 'undefined') {
this._interruptScrollAnimation(); this._interruptScrollAnimation();

View File

@@ -9,9 +9,13 @@ import { links, getIndex, getLink } from './tabs_bar';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading'; import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error'; import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
import detectPassiveEvents from 'detect-passive-events';
import { scrollRight } from '../../../scroll';
const componentMap = { const componentMap = {
'COMPOSE': Compose, 'COMPOSE': Compose,
'HOME': HomeTimeline, 'HOME': HomeTimeline,
@@ -22,7 +26,7 @@ const componentMap = {
'FAVOURITES': FavouritedStatuses, 'FAVOURITES': FavouritedStatuses,
}; };
@injectIntl @component => injectIntl(component, { withRef: true })
export default class ColumnsArea extends ImmutablePureComponent { export default class ColumnsArea extends ImmutablePureComponent {
static contextTypes = { static contextTypes = {
@@ -45,15 +49,39 @@ export default class ColumnsArea extends ImmutablePureComponent {
} }
componentDidMount() { componentDidMount() {
if (!this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
}
this.lastIndex = getIndex(this.context.router.history.location.pathname); this.lastIndex = getIndex(this.context.router.history.location.pathname);
this.setState({ shouldAnimate: true }); this.setState({ shouldAnimate: true });
} }
componentDidUpdate() { componentWillUpdate(nextProps) {
if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
}
componentDidUpdate(prevProps) {
if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
}
this.lastIndex = getIndex(this.context.router.history.location.pathname); this.lastIndex = getIndex(this.context.router.history.location.pathname);
this.setState({ shouldAnimate: true }); this.setState({ shouldAnimate: true });
} }
componentWillUnmount () {
if (!this.props.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}
}
handleChildrenContentChange() {
if (!this.props.singleColumn) {
scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
}
}
handleSwipe = (index) => { handleSwipe = (index) => {
this.pendingIndex = index; this.pendingIndex = index;
@@ -74,6 +102,18 @@ export default class ColumnsArea extends ImmutablePureComponent {
} }
} }
handleWheel = () => {
if (typeof this._interruptScrollAnimation !== 'function') {
return;
}
this._interruptScrollAnimation();
}
setRef = (node) => {
this.node = node;
}
renderView = (link, index) => { renderView = (link, index) => {
const columnIndex = getIndex(this.context.router.history.location.pathname); const columnIndex = getIndex(this.context.router.history.location.pathname);
const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] }); const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
@@ -90,8 +130,8 @@ export default class ColumnsArea extends ImmutablePureComponent {
); );
} }
renderLoading = () => { renderLoading = columnId => () => {
return <ColumnLoading />; return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
} }
renderError = (props) => { renderError = (props) => {
@@ -114,12 +154,12 @@ export default class ColumnsArea extends ImmutablePureComponent {
} }
return ( return (
<div className='columns-area'> <div className='columns-area' ref={this.setRef}>
{columns.map(column => { {columns.map(column => {
const params = column.get('params', null) === null ? null : column.get('params').toJS(); const params = column.get('params', null) === null ? null : column.get('params').toJS();
return ( return (
<BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading} error={this.renderError}> <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />} {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />}
</BundleContainer> </BundleContainer>
); );

View File

@@ -0,0 +1,11 @@
import React from 'react';
const DrawerLoading = () => (
<div className='drawer'>
<div className='drawer__pager'>
<div className='drawer__inner' />
</div>
</div>
);
export default DrawerLoading;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage, injectIntl } from 'react-intl';
import axios from 'axios';
@injectIntl
export default class EmbedModal extends ImmutablePureComponent {
static propTypes = {
url: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
state = {
loading: false,
oembed: null,
};
componentDidMount () {
const { url } = this.props;
this.setState({ loading: true });
axios.post('/api/web/embed', { url }).then(res => {
this.setState({ loading: false, oembed: res.data });
const iframeDocument = this.iframe.contentWindow.document;
iframeDocument.open();
iframeDocument.write(res.data.html);
iframeDocument.close();
iframeDocument.body.style.margin = 0;
this.iframe.width = iframeDocument.body.scrollWidth;
this.iframe.height = iframeDocument.body.scrollHeight;
});
}
setIframeRef = c => {
this.iframe = c;
}
handleTextareaClick = (e) => {
e.target.select();
}
render () {
const { oembed } = this.state;
return (
<div className='modal-root__modal embed-modal'>
<h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4>
<div className='embed-modal__container'>
<p className='hint'>
<FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
</p>
<input
type='text'
className='embed-modal__html'
readOnly
value={oembed && oembed.html || ''}
onClick={this.handleTextareaClick}
/>
<p className='hint'>
<FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
</p>
<iframe
className='embed-modal__iframe'
frameBorder='0'
ref={this.setIframeRef}
title='preview'
/>
</div>
</div>
);
}
}

View File

@@ -5,24 +5,26 @@ import spring from 'react-motion/lib/spring';
import BundleContainer from '../containers/bundle_container'; import BundleContainer from '../containers/bundle_container';
import BundleModalError from './bundle_modal_error'; import BundleModalError from './bundle_modal_error';
import ModalLoading from './modal_loading'; import ModalLoading from './modal_loading';
import ActionsModal from '../components/actions_modal'; import ActionsModal from './actions_modal';
import MediaModal from './media_modal';
import VideoModal from './video_modal';
import BoostModal from './boost_modal';
import ConfirmationModal from './confirmation_modal';
import { import {
MediaModal,
OnboardingModal, OnboardingModal,
VideoModal,
BoostModal,
ConfirmationModal,
ReportModal, ReportModal,
EmbedModal,
} from '../../../features/ui/util/async-components'; } from '../../../features/ui/util/async-components';
const MODAL_COMPONENTS = { const MODAL_COMPONENTS = {
'MEDIA': MediaModal, 'MEDIA': () => Promise.resolve({ default: MediaModal }),
'ONBOARDING': OnboardingModal, 'ONBOARDING': OnboardingModal,
'VIDEO': VideoModal, 'VIDEO': () => Promise.resolve({ default: VideoModal }),
'BOOST': BoostModal, 'BOOST': () => Promise.resolve({ default: BoostModal }),
'CONFIRM': ConfirmationModal, 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
'REPORT': ReportModal, 'REPORT': ReportModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {
@@ -80,8 +82,8 @@ export default class ModalRoot extends React.PureComponent {
return { opacity: spring(0), scale: spring(0.98) }; return { opacity: spring(0), scale: spring(0.98) };
} }
renderLoading = () => { renderLoading = modalId => () => {
return <ModalLoading />; return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
} }
renderError = (props) => { renderError = (props) => {
@@ -115,7 +117,7 @@ export default class ModalRoot extends React.PureComponent {
<div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}> <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
<div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading} error={this.renderError} renderDelay={200}> <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />} {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
</BundleContainer> </BundleContainer>
</div> </div>

View File

@@ -30,7 +30,7 @@ const PageOne = ({ acct, domain }) => (
<div> <div>
<h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1>
<p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p>
<p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p> <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>@{acct}@{domain}</strong> }} /></p>
</div> </div>
</div> </div>
); );

View File

@@ -12,13 +12,12 @@ export default class UploadArea extends React.PureComponent {
}; };
handleKeyUp = (e) => { handleKeyUp = (e) => {
e.preventDefault();
e.stopPropagation();
const keyCode = e.keyCode; const keyCode = e.keyCode;
if (this.props.active) { if (this.props.active) {
switch(keyCode) { switch(keyCode) {
case 27: case 27:
e.preventDefault();
e.stopPropagation();
this.props.onClose(); this.props.onClose();
break; break;
} }

View File

@@ -5,4 +5,4 @@ const mapStateToProps = state => ({
columns: state.getIn(['settings', 'columns']), columns: state.getIn(['settings', 'columns']),
}); });
export default connect(mapStateToProps)(ColumnsArea); export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);

View File

@@ -1,17 +1,17 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import Redirect from 'react-router-dom/Redirect';
import NotificationsContainer from './containers/notifications_container'; import NotificationsContainer from './containers/notifications_container';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import LoadingBarContainer from './containers/loading_bar_container'; import LoadingBarContainer from './containers/loading_bar_container';
import TabsBar from './components/tabs_bar'; import TabsBar from './components/tabs_bar';
import ModalContainer from './containers/modal_container'; import ModalContainer from './containers/modal_container';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Redirect, withRouter } from 'react-router-dom';
import { isMobile } from '../../is_mobile'; import { isMobile } from '../../is_mobile';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { uploadCompose } from '../../actions/compose'; import { uploadCompose } from '../../actions/compose';
import { refreshHomeTimeline } from '../../actions/timelines'; import { refreshHomeTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications'; import { refreshNotifications } from '../../actions/notifications';
import { clearStatusesHeight } from '../../actions/statuses';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area'; import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container'; import ColumnsAreaContainer from './containers/columns_area_container';
@@ -35,6 +35,7 @@ import {
FavouritedStatuses, FavouritedStatuses,
Blocks, Blocks,
Mutes, Mutes,
PinnedStatuses,
} from './util/async-components'; } from './util/async-components';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
@@ -42,11 +43,11 @@ import {
import '../../components/status'; import '../../components/status';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
systemFontUi: state.getIn(['meta', 'system_font_ui']),
isComposing: state.getIn(['compose', 'is_composing']), isComposing: state.getIn(['compose', 'is_composing']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@withRouter
export default class UI extends React.PureComponent { export default class UI extends React.PureComponent {
static contextTypes = { static contextTypes = {
@@ -56,8 +57,8 @@ export default class UI extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
children: PropTypes.node, children: PropTypes.node,
systemFontUi: PropTypes.bool,
isComposing: PropTypes.bool, isComposing: PropTypes.bool,
location: PropTypes.object,
}; };
state = { state = {
@@ -66,6 +67,9 @@ export default class UI extends React.PureComponent {
}; };
handleResize = debounce(() => { handleResize = debounce(() => {
// The cached heights are no longer accurate, invalidate
this.props.dispatch(clearStatusesHeight());
this.setState({ width: window.innerWidth }); this.setState({ width: window.innerWidth });
}, 500, { }, 500, {
trailing: true, trailing: true,
@@ -131,7 +135,7 @@ export default class UI extends React.PureComponent {
if (data.type === 'navigate') { if (data.type === 'navigate') {
this.context.router.history.push(data.path); this.context.router.history.push(data.path);
} else { } else {
console.warn('Unknown message type:', data.type); // eslint-disable-line no-console console.warn('Unknown message type:', data.type);
} }
} }
@@ -164,6 +168,12 @@ export default class UI extends React.PureComponent {
return true; return true;
} }
componentDidUpdate (prevProps) {
if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
this.columnsAreaNode.handleChildrenContentChange();
}
}
componentWillUnmount () { componentWillUnmount () {
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragenter', this.handleDragEnter);
@@ -177,18 +187,18 @@ export default class UI extends React.PureComponent {
this.node = c; this.node = c;
} }
setColumnsAreaRef = (c) => {
this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
}
render () { render () {
const { width, draggingOver } = this.state; const { width, draggingOver } = this.state;
const { children } = this.props; const { children } = this.props;
const className = classNames('ui', {
'system-font': this.props.systemFontUi,
});
return ( return (
<div className={className} ref={this.setRef}> <div className='ui' ref={this.setRef}>
<TabsBar /> <TabsBar />
<ColumnsAreaContainer singleColumn={isMobile(width)}> <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
<WrappedSwitch> <WrappedSwitch>
<Redirect from='/' to='/getting-started' exact /> <Redirect from='/' to='/getting-started' exact />
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
@@ -199,6 +209,7 @@ export default class UI extends React.PureComponent {
<WrappedRoute path='/notifications' component={Notifications} content={children} /> <WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />

View File

@@ -34,6 +34,10 @@ export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started'); return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
} }
export function PinnedStatuses () {
return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
}
export function AccountTimeline () { export function AccountTimeline () {
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline'); return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
} }
@@ -78,26 +82,10 @@ export function Mutes () {
return import(/* webpackChunkName: "features/mutes" */'../../mutes'); return import(/* webpackChunkName: "features/mutes" */'../../mutes');
} }
export function MediaModal () {
return import(/* webpackChunkName: "modals/media_modal" */'../components/media_modal');
}
export function OnboardingModal () { export function OnboardingModal () {
return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'); return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
} }
export function VideoModal () {
return import(/* webpackChunkName: "modals/video_modal" */'../components/video_modal');
}
export function BoostModal () {
return import(/* webpackChunkName: "modals/boost_modal" */'../components/boost_modal');
}
export function ConfirmationModal () {
return import(/* webpackChunkName: "modals/confirmation_modal" */'../components/confirmation_modal');
}
export function ReportModal () { export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
} }
@@ -109,3 +97,7 @@ export function MediaGallery () {
export function VideoPlayer () { export function VideoPlayer () {
return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
} }
export function EmbedModal () {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
}

View File

@@ -47,7 +47,7 @@
"compose_form.lock_disclaimer.lock": "مقفل", "compose_form.lock_disclaimer.lock": "مقفل",
"compose_form.placeholder": "فيمَ تفكّر؟", "compose_form.placeholder": "فيمَ تفكّر؟",
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
"compose_form.publish": "بوّق !", "compose_form.publish": "بوّق",
"compose_form.publish_loud": "{publish}!", "compose_form.publish_loud": "{publish}!",
"compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس", "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
"compose_form.spoiler": "أخفِ النص واعرض تحذيرا", "compose_form.spoiler": "أخفِ النص واعرض تحذيرا",
@@ -63,6 +63,8 @@
"confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
"confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"embed.instructions": "Embed this status on your website by copying the code below.",
"embed.preview": "Here is what it will look like:",
"emoji_button.activity": "الأنشطة", "emoji_button.activity": "الأنشطة",
"emoji_button.flags": "الأعلام", "emoji_button.flags": "الأعلام",
"emoji_button.food": "الطعام والشراب", "emoji_button.food": "الطعام والشراب",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...", "standalone.public_title": "A look inside...",
"status.cannot_reblog": "تعذرت ترقية هذا المنشور", "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
"status.delete": "إحذف", "status.delete": "إحذف",
"status.embed": "Embed",
"status.favourite": "أضف إلى المفضلة", "status.favourite": "أضف إلى المفضلة",
"status.load_more": "حمّل المزيد", "status.load_more": "حمّل المزيد",
"status.media_hidden": "الصورة مستترة", "status.media_hidden": "الصورة مستترة",
"status.mention": "أذكُر @{name}", "status.mention": "أذكُر @{name}",
"status.mute_conversation": "Mute conversation", "status.mute_conversation": "Mute conversation",
"status.open": "وسع هذه المشاركة", "status.open": "وسع هذه المشاركة",
"status.pin": "Pin on profile",
"status.reblog": "رَقِّي", "status.reblog": "رَقِّي",
"status.reblogged_by": "{name} رقى", "status.reblogged_by": "{name} رقى",
"status.reply": "ردّ", "status.reply": "ردّ",
@@ -179,6 +183,7 @@
"status.show_less": "إعرض أقلّ", "status.show_less": "إعرض أقلّ",
"status.show_more": "أظهر المزيد", "status.show_more": "أظهر المزيد",
"status.unmute_conversation": "Unmute conversation", "status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"tabs_bar.compose": "تحرير", "tabs_bar.compose": "تحرير",
"tabs_bar.federated_timeline": "الموحَّد", "tabs_bar.federated_timeline": "الموحَّد",
"tabs_bar.home": "الرئيسية", "tabs_bar.home": "الرئيسية",

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