Compare commits

..

104 Commits
v0.8 ... v0.9

Author SHA1 Message Date
Eugen Rochko
bf5f55a6bb Fix live status removal from public/hashtag channels 2016-11-10 00:47:47 +01:00
Eugen Rochko
aabf884c5f Discard misattributed remote statuses, improve timelines filter 2016-11-10 00:15:49 +01:00
Eugen Rochko
17903c6dae Improve filtering of public/hashtag timelines, both in backlog and real-time 2016-11-10 00:03:33 +01:00
Eugen Rochko
c5e03a2e0d Status removal is broadcast to public/hashtag timelines too 2016-11-09 19:16:27 +01:00
Eugen Rochko
448ab18a20 Replace setting custom CORS headers with rack-cors, set it on /oauth/token endpoint 2016-11-09 18:06:01 +01:00
Eugen Rochko
b13e7dda1f API pagination for all collections using Link header 2016-11-09 17:48:44 +01:00
Eugen Rochko
8d7fc5da6c Update README with attribution links and FAQ link 2016-11-08 23:33:59 +01:00
Eugen Rochko
9aecc0f48a Move timelines API from statuses to its own controller, add a check for
resources that require a user context vs those that don't (such as public timeline)

/api/v1/statuses/public   -> /api/v1/timelines/public
/api/v1/statuses/home     -> /api/v1/timelines/home
/api/v1/statuses/mentions -> /api/v1/timelines/mentions
/api/v1/statuses/tag/:tag -> /api/v1/timelines/tag/:tag
2016-11-08 23:29:08 +01:00
Eugen Rochko
86574ea524 Adding Emoji One 2016-11-08 21:46:29 +01:00
Eugen Rochko
b6832553ff Adding more logging 2016-11-08 19:37:08 +01:00
Eugen Rochko
cff0b03cbb Fix for thread resolve service and process feed service url method 2016-11-08 19:09:22 +01:00
Eugen Rochko
2f21f4cc01 Fix region setting for AWS gem 2016-11-08 18:55:46 +01:00
Eugen Rochko
2d69bf4b3a Fix 50x page image for non-/ paths 2016-11-08 02:18:09 +01:00
Eugen Rochko
bb4d1eb2e8 Improve feed regeneration 2016-11-08 02:08:32 +01:00
Eugen Rochko
096bfbad96 Fix typo 2016-11-08 01:48:17 +01:00
Eugen Rochko
11cbe49ffc ProcessFeedService refactor 2016-11-08 01:44:51 +01:00
Eugen Rochko
dbe00a4156 Improved configuration from ENV, cleaned up timeline filter methods
to be more readable, add extra logging to process feed service
2016-11-07 23:20:52 +01:00
Eugen Rochko
0400734df7 Click video player to toggle playback 2016-11-07 19:05:32 +01:00
Eugen Rochko
a39a92bd22 Fix #135 - More prominent focus style for inputs 2016-11-07 18:45:44 +01:00
Eugen Rochko
bec47e40f5 Better look/behaviours for video player/lightbox 2016-11-07 18:42:39 +01:00
Eugen Rochko
d0d799f911 Replace react-skylight with own solution that centers images better 2016-11-07 18:23:36 +01:00
Eugen Rochko
f5c6baf29d Fix account and hashtag regex 2016-11-07 17:35:25 +01:00
Eugen Rochko
c3559d18a3 Do not modify scroll position unless content has been added to the top, not bottom 2016-11-07 02:11:38 +01:00
Eugen Rochko
4a6cc46e81 Keep scroll position when scrolled down and new content is added 2016-11-07 02:02:55 +01:00
Eugen Rochko
23fc424b7a Account notes (bios) can now contain links. Character limit upped to 160 2016-11-07 01:14:12 +01:00
Eugen Rochko
b835f4aa1c Fix insecure S3 URLs 2016-11-06 20:59:06 +01:00
Eugen Rochko
ea4170785a Downgrade aws gem as this one was incompatible with Paperclip 2016-11-06 20:55:57 +01:00
Eugen Rochko
d14967e1c8 Fix URL configuration when S3 is enabled 2016-11-06 20:43:16 +01:00
Eugen Rochko
45230c56ab Improve S3 config 2016-11-06 18:55:20 +01:00
Eugen Rochko
3ab193bc3f Adding optional S3, fail-mastodon 2016-11-06 18:35:46 +01:00
Eugen Rochko
93212bc2c4 Add test for FanOutOnWriteService 2016-11-06 15:56:34 +01:00
Eugen Rochko
7bb28bf780 Improve @mention regex 2016-11-05 22:09:51 +01:00
Eugen Rochko
262adb9791 Fix remote hashtag linking 2016-11-05 17:54:19 +01:00
Eugen Rochko
eb47409036 Fix mistake 2016-11-05 17:48:53 +01:00
Eugen Rochko
a698b767c1 Fix linking of remote hashtags in UI, add public view of hashtags 2016-11-05 17:44:14 +01:00
Eugen Rochko
48b9619439 Adding hashtags 2016-11-05 17:13:14 +01:00
Eugen Rochko
62292797ec Adding hashtag model 2016-11-04 19:12:59 +01:00
Eugen Rochko
6471a548fe Add pghero for analyzing postgres performance in production 2016-11-04 18:35:04 +01:00
Eugen Rochko
4ea13d8dc9 Fix web app manifests 2016-11-04 14:19:58 +01:00
Eugen Rochko
2463c72006 Add forgotten reducer for FAVOURITES_FETCH_SUCCESS 2016-11-04 14:15:25 +01:00
Eugen Rochko
087b993892 Adding list of who favourited status 2016-11-04 13:39:24 +01:00
Eugen Rochko
6d5ef89356 Reduce code duplication, add touch scrolling behaviour for webkit browsers
on scrollable areas
2016-11-04 13:32:14 +01:00
Eugen Rochko
98c3a5e9c3 Optimize how statuses are re-rendered and relative time intervals 2016-11-04 12:48:53 +01:00
Eugen Rochko
6d26bfd147 Add list of who reblogged status 2016-11-03 20:16:14 +01:00
Eugen Rochko
0405ef977a Aria-labels actually didn't work in React, so removing. Fixing some bugs 2016-11-03 19:52:09 +01:00
Eugen Rochko
159203a7bc Fix #136: Add aria-labels 2016-11-03 19:19:39 +01:00
Eugen Rochko
1828df9bc0 Disable accounts caching too 2016-11-03 19:06:11 +01:00
Eugen Rochko
abf664b560 Disable caching of statuses (maybe this will help with the weird bug) 2016-11-03 18:55:55 +01:00
Eugen Rochko
829d189f44 Add tests for recently added API end points 2016-11-03 18:53:37 +01:00
Eugen Rochko
3731230c6d Allow @username@domain/@username in follow form, prevent duplicate accounts
created via remote look-up when domains differ but point to the same resource
2016-11-03 16:57:44 +01:00
Eugen Rochko
e4671adc25 Fix reblogged/favourited caching; add API endpoints for who favd/reblogged status 2016-11-03 14:50:22 +01:00
Eugen Rochko
c003e70758 Caching should work correctly now (fingers crossed) 2016-11-03 14:12:45 +01:00
Eugen Rochko
562044f36a Need to disable caching again due to bug in Rabl 2016-11-03 13:59:31 +01:00
Eugen Rochko
0160d1d9b5 Enable caching for some rabl views 2016-11-03 13:28:36 +01:00
Eugen Rochko
aff22bfdb5 Add own composed status to home without waiting for websocket event
This makes the UI appear less laggy. After all, the status *is* created
right there and then, it's the distribution to home timelines that can take
a while
2016-11-03 11:06:55 +01:00
Eugen Rochko
4b357ecf98 Fix subtle bugs, new icon button 2016-11-02 22:29:19 +01:00
Eugen
d427df4a8a Merge pull request #132 from marcus-herrmann/master
a11y: Improve icon button component regarding semantics, screen reader usage
2016-11-02 21:53:05 +01:00
Eugen
76a717d549 Merge pull request #129 from fhemberger/fix/a11y-labels
a11y: Add labels to input fields for screenreader
2016-11-02 21:53:00 +01:00
mhe
87aabeb7af a11y: Improve icon button component regarding semantics and screen reader usage 2016-11-02 20:18:39 +01:00
Eugen
77045d0886 Merge pull request #130 from fhemberger/feature/manifest
Add touch icons for Chrome, iOS and Windows 10
2016-11-02 17:41:32 +01:00
Eugen
bc1dba22c5 Fix syntax errors 2016-11-02 17:13:47 +01:00
Eugen Rochko
e7723ab662 Add puma preloading, decrease DB pool size to size of threads per worker 2016-11-02 16:38:54 +01:00
Frederic Hemberger
e7cd6da938 Add touch icons for Chrome, iOS and Windows 10 2016-11-02 16:05:08 +01:00
Frederic Hemberger
5ff2762fcf a11y: Add labels to input fields for screenreader
See: https://dequeuniversity.com/rules/axe/2.1/label
2016-11-02 14:53:39 +01:00
Eugen Rochko
53fb7b1aa8 Replace in-memory cache store with redis store, should decrease memory
footprint of each running process by 128mb
2016-11-02 13:30:52 +01:00
Eugen Rochko
a59d10d3df Fix missing default headers 2016-11-02 13:12:18 +01:00
Eugen Rochko
9467b900a2 Make cookies https-only if LOCAL_HTTPS is true, set X-Frame-Options to DENY,
add permissive CORS to API controllers
2016-11-02 12:58:15 +01:00
Eugen
0a6b5e2c17 Merge pull request #125 from fhemberger/fix/a11y-language
a11y: Add HTML lang attribute and utf8 encoding meta tag
2016-11-02 11:04:27 +01:00
Frederic Hemberger
d991869498 a11y: Add HTML lang attribute and utf8 encoding meta tag
See: https://dequeuniversity.com/rules/axe/2.1/html-has-lang
2016-11-02 11:01:18 +01:00
Eugen Rochko
dd03118098 Fix follow icon changing plus to minus, add terms page stub 2016-11-01 18:05:55 +01:00
Eugen Rochko
57304ac375 Catch 404 errors when downloading remote media, add screenshot to landing page 2016-11-01 16:58:13 +01:00
Eugen Rochko
bab4904492 Actually fix the reblogs/favs reducer issue 2016-10-31 18:11:49 +01:00
Eugen
d88d148d89 Fix forgotten reducers for reblogs/favourites 2016-10-31 16:59:36 +01:00
Eugen
3692051561 Updated screenshot 2016-10-31 12:39:41 +01:00
Eugen
50aaf4667f Fix accounts not being normalized from context statuses 2016-10-31 01:27:19 +01:00
Eugen Rochko
7e35650398 Actually complete the autosuggestion feature 2016-10-30 18:21:38 +01:00
Eugen Rochko
c49f6290eb Basic username autocomplete for text area 2016-10-30 18:13:05 +01:00
Eugen Rochko
fa1cc2d05a Limit returned followees/followers by API to 40 for now 2016-10-30 15:14:07 +01:00
Eugen Rochko
f10feb2e70 Fix common followers API 2016-10-30 15:08:36 +01:00
Eugen Rochko
e8ff4c8e56 Refactoring redux state into different reducers 2016-10-30 15:06:43 +01:00
Eugen Rochko
7060bdf04b Hide notes on user lists for now 2016-10-29 01:31:33 +02:00
Eugen Rochko
e0a197650a Adding common followers API, fixing fallback query again 2016-10-29 01:29:19 +02:00
Eugen Rochko
c913bdfc98 Fix follow suggestion fallback to exclude already followed users 2016-10-28 20:35:30 +02:00
Eugen Rochko
bfb6cc5f2c Make suggestions box also use user list components 2016-10-28 20:26:49 +02:00
Eugen Rochko
ac4f53a3a2 Improved how user lists look, added follow button to them 2016-10-28 20:05:44 +02:00
Eugen Rochko
1c84d505c8 Adding following/followers lists to the UI 2016-10-27 21:59:56 +02:00
Eugen Rochko
909d0d5e88 Adding public timeline silencing 2016-10-27 19:33:04 +02:00
Eugen Rochko
e5565a7e4a Fix suggestions fallback query 2016-10-27 19:03:05 +02:00
Eugen Rochko
19615a2c37 Add restart policy to docker compose 2016-10-25 13:21:48 +02:00
Eugen Rochko
c1a0633a12 Guard against unexisting statuses rendering 2016-10-25 11:13:16 +02:00
Eugen Rochko
de50eff6ac Add opening images in a modal window 2016-10-24 18:08:23 +02:00
Eugen Rochko
f8f40f15da Move status components inside individual containers. We still need to select
all statuses/accounts to assemble, but at least lists don't have to be
re-rendered all the time now. Also add "mention" dropdown option
2016-10-24 18:08:23 +02:00
Eugen
61db14bcbe Add more useful links to the readme 2016-10-24 12:37:06 +02:00
Eugen Rochko
720d1f8f3d Restrict access to oauth/applications to admins only 2016-10-23 12:08:52 +02:00
Eugen Rochko
b746a931a5 Fix OAuth authorization redirect 2016-10-23 12:05:55 +02:00
Eugen Rochko
abb8f5837e Fix public channel 2016-10-23 11:56:04 +02:00
Eugen Rochko
a9e40a3d80 Adding OAuth access scopes, fixing OAuth authorization UI, adding rate limiting
to the API
2016-10-22 19:39:44 +02:00
Eugen
17122df80d Merge pull request #101 from kschaper/master
Add specs for Button, DisplayName, DropdownMenu components
2016-10-20 18:39:30 +02:00
Eugen Rochko
852c82435d Fix method return when rescuing 2016-10-20 18:36:12 +02:00
Kai Schaper
4b0e3eb1fb Merge remote-tracking branch 'upstream/master' 2016-10-20 17:35:05 +02:00
Kai Schaper
19cfe64455 add/update specs for Button, DisplayName, DropdownMenu 2016-10-20 17:34:44 +02:00
Eugen Rochko
04bbc57690 Fix #100 - Add "back" button to certain views
Also fix reloading of timelines after merge-type events
2016-10-19 18:20:19 +02:00
Eugen Rochko
8698cd3281 Remember scroll position when navigating back, do not needlessly reload
entire timelines (only fetch since last known ID). Side effect: account
timelines no longer update in real-time
2016-10-18 23:06:28 +02:00
Eugen Rochko
1d2175f73c Do not force-transcode uploaded videos to webm (leave mp4 as-is) 2016-10-18 17:51:45 +02:00
2022 changed files with 3782 additions and 1208 deletions

View File

@@ -1,14 +1,60 @@
Rails: Rails:
Enabled: true Enabled: true
Metrics/LineLength:
Enabled: false
Style/PerlBackrefs: Style/PerlBackrefs:
AutoCorrect: false AutoCorrect: false
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
Enabled: false Enabled: false
Documentation: Metrics/BlockNesting:
Max: 2
Metrics/LineLength:
AllowURI: true
Enabled: false Enabled: false
Metrics/MethodLength:
CountComments: false
Max: 10
Metrics/ModuleLength:
Max: 100
Metrics/ParameterLists:
Max: 4
CountKeywordArgs: true
Style/AccessModifierIndentation:
EnforcedStyle: indent
Style/CollectionMethods:
Enabled: true
PreferredMethods:
find_all: 'select'
Style/Documentation:
Enabled: false
Style/DoubleNegation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/SpaceInsideHashLiteralBraces:
EnforcedStyle: space
Style/TrailingCommaInLiteral:
EnforcedStyleForMultiline: 'comma'
Style/RegexpLiteral:
Enabled: false
AllCops:
TargetRubyVersion: 2.2
Exclude:
- 'spec/**/*'
- 'db/**/*'
- 'app/views/**/*'
- 'config/**/*'

View File

@@ -4,7 +4,6 @@ gem 'rails', '5.0.0.1'
gem 'sass-rails', '~> 5.0' gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0' gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.1.0' gem 'coffee-rails', '~> 4.1.0'
gem 'therubyracer', platforms: :ruby
gem 'jquery-rails' gem 'jquery-rails'
gem 'jbuilder', '~> 2.0' gem 'jbuilder', '~> 2.0'
gem 'sdoc', '~> 0.4.0', group: :doc gem 'sdoc', '~> 0.4.0', group: :doc
@@ -12,11 +11,13 @@ gem 'puma'
gem 'hamlit-rails' gem 'hamlit-rails'
gem 'pg' gem 'pg'
gem 'pghero'
gem 'dotenv-rails' gem 'dotenv-rails'
gem 'font-awesome-rails' gem 'font-awesome-rails'
gem 'paperclip', '~> 4.3' gem 'paperclip', '~> 4.3'
gem 'paperclip-av-transcoder' gem 'paperclip-av-transcoder'
gem 'aws-sdk', '< 2.0'
gem 'http' gem 'http'
gem 'httplog' gem 'httplog'
@@ -37,12 +38,14 @@ gem 'htmlentities'
gem 'simple_form' gem 'simple_form'
gem 'will_paginate' gem 'will_paginate'
gem 'rack-attack' gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors'
gem 'sidekiq' gem 'sidekiq'
gem 'ledermann-rails-settings' gem 'ledermann-rails-settings'
gem 'neography' gem 'neography'
gem 'react-rails' gem 'react-rails'
gem 'browserify-rails' gem 'browserify-rails'
gem 'autoprefixer-rails'
group :development, :test do group :development, :test do
gem 'rspec-rails' gem 'rspec-rails'
@@ -69,4 +72,5 @@ end
group :production do group :production do
gem 'rails_12factor' gem 'rails_12factor'
gem 'lograge' gem 'lograge'
gem 'redis-rails'
end end

View File

@@ -42,8 +42,15 @@ GEM
addressable (2.4.0) addressable (2.4.0)
arel (7.1.1) arel (7.1.1)
ast (2.3.0) ast (2.3.0)
autoprefixer-rails (6.5.0.2)
execjs
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
aws-sdk (1.66.0)
aws-sdk-v1 (= 1.66.0)
aws-sdk-v1 (1.66.0)
json (~> 1.4)
nokogiri (>= 1.4.4)
babel-source (5.8.35) babel-source (5.8.35)
babel-transpiler (0.7.0) babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6) babel-source (>= 4.0, < 6)
@@ -150,7 +157,6 @@ GEM
activerecord (>= 3.1) activerecord (>= 3.1)
letter_opener (1.4.1) letter_opener (1.4.1)
launchy (~> 2.2) launchy (~> 2.2)
libv8 (3.16.14.15)
link_header (0.0.8) link_header (0.0.8)
lograge (0.4.1) lograge (0.4.1)
actionpack (>= 4, < 5.1) actionpack (>= 4, < 5.1)
@@ -197,6 +203,8 @@ GEM
parser (2.3.1.2) parser (2.3.1.2)
ast (~> 2.2) ast (~> 2.2)
pg (0.18.4) pg (0.18.4)
pghero (1.6.2)
activerecord
powerpack (0.1.1) powerpack (0.1.1)
pry (0.10.4) pry (0.10.4)
coderay (~> 1.1.0) coderay (~> 1.1.0)
@@ -205,11 +213,12 @@ GEM
pry-rails (0.3.4) pry-rails (0.3.4)
pry (>= 0.9.10) pry (>= 0.9.10)
puma (3.6.0) puma (3.6.0)
rabl (0.13.0) rabl (0.13.1)
activesupport (>= 2.3.14) activesupport (>= 2.3.14)
rack (2.0.1) rack (2.0.1)
rack-attack (5.0.1) rack-attack (5.0.1)
rack rack
rack-cors (0.4.0)
rack-protection (1.5.3) rack-protection (1.5.3)
rack rack
rack-test (0.6.3) rack-test (0.6.3)
@@ -256,7 +265,22 @@ GEM
railties (>= 3.2) railties (>= 3.2)
tilt tilt
redis (3.3.1) redis (3.3.1)
ref (2.0.0) redis-actionpack (5.0.0)
actionpack (>= 4.0.0, < 6)
redis-rack (~> 2.0.0.pre)
redis-store (~> 1.2.0.pre)
redis-activesupport (5.0.1)
activesupport (>= 3, < 6)
redis-store (~> 1.2.0)
redis-rack (2.0.0)
rack (~> 2.0)
redis-store (~> 1.2.0)
redis-rails (5.0.1)
redis-actionpack (~> 5.0.0)
redis-activesupport (~> 5.0.0)
redis-store (~> 1.2.0)
redis-store (1.2.0)
redis (>= 2.2)
responders (2.3.0) responders (2.3.0)
railties (>= 4.2.0, < 5.1) railties (>= 4.2.0, < 5.1)
rspec (3.5.0) rspec (3.5.0)
@@ -324,9 +348,6 @@ GEM
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
temple (0.7.7) temple (0.7.7)
therubyracer (0.12.2)
libv8 (~> 3.16.14.0)
ref
thor (0.19.1) thor (0.19.1)
thread_safe (0.3.5) thread_safe (0.3.5)
tilt (2.0.5) tilt (2.0.5)
@@ -356,6 +377,8 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
active_record_query_trace active_record_query_trace
addressable addressable
autoprefixer-rails
aws-sdk (< 2.0)
better_errors better_errors
binding_of_caller binding_of_caller
browserify-rails browserify-rails
@@ -387,15 +410,18 @@ DEPENDENCIES
paperclip (~> 4.3) paperclip (~> 4.3)
paperclip-av-transcoder paperclip-av-transcoder
pg pg
pghero
pry-rails pry-rails
puma puma
rabl rabl
rack-attack rack-attack
rack-cors
rails (= 5.0.0.1) rails (= 5.0.0.1)
rails_12factor rails_12factor
rails_autolink rails_autolink
react-rails react-rails
redis (~> 3.2) redis (~> 3.2)
redis-rails
rspec-rails rspec-rails
rspec-sidekiq rspec-sidekiq
rubocop rubocop
@@ -404,7 +430,6 @@ DEPENDENCIES
sidekiq sidekiq
simple_form simple_form
simplecov simplecov
therubyracer
uglifier (>= 1.3.0) uglifier (>= 1.3.0)
webmock webmock
will_paginate will_paginate

View File

@@ -7,11 +7,13 @@ Mastodon
[travis]: https://travis-ci.org/Gargron/mastodon [travis]: https://travis-ci.org/Gargron/mastodon
[code_climate]: https://codeclimate.com/github/Gargron/mastodon [code_climate]: https://codeclimate.com/github/Gargron/mastodon
Mastodon is a federated microblogging engine. An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon. Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
Click on the screenshot to watch a demo of the UI: Click on the screenshot to watch a demo of the UI:
[![Screenshot](https://i.imgur.com/hzWZdYT.png)][youtube_demo] [![Screenshot](https://i.imgur.com/pNieDFp.png)][youtube_demo]
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
@@ -21,12 +23,13 @@ If you would like, you can [support the development of this project on Patreon][
[patreon]: https://www.patreon.com/user?u=619786 [patreon]: https://www.patreon.com/user?u=619786
**Current status of the project is early development**
## Resources ## Resources
- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
- [API overview](https://github.com/Gargron/mastodon/wiki/API) - [API overview](https://github.com/Gargron/mastodon/wiki/API)
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL) - [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
## Features ## Features
@@ -113,3 +116,10 @@ Which will re-create the updated containers, leaving databases and data as is. D
### Contributing ### Contributing
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future. You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
### Extra credits
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
![Mastodon error image](https://mastodon.social/oops.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

View File

@@ -1,5 +1,4 @@
import api from '../api' import api from '../api'
import axios from 'axios';
import Immutable from 'immutable'; import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF'; export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
@@ -32,6 +31,18 @@ export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS'; export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL'; export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
export function setAccountSelf(account) { export function setAccountSelf(account) {
return { return {
type: ACCOUNT_SET_SELF, type: ACCOUNT_SET_SELF,
@@ -41,24 +52,32 @@ export function setAccountSelf(account) {
export function fetchAccount(id) { export function fetchAccount(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
const boundApi = api(getState);
dispatch(fetchAccountRequest(id)); dispatch(fetchAccountRequest(id));
axios.all([boundApi.get(`/api/v1/accounts/${id}`), boundApi.get(`/api/v1/accounts/relationships?id=${id}`)]).then(values => { api(getState).get(`/api/v1/accounts/${id}`).then(response => {
dispatch(fetchAccountSuccess(values[0].data, values[1].data[0])); dispatch(fetchAccountSuccess(response.data));
dispatch(fetchRelationships([id]));
}).catch(error => { }).catch(error => {
dispatch(fetchAccountFail(id, error)); dispatch(fetchAccountFail(id, error));
}); });
}; };
}; };
export function fetchAccountTimeline(id) { export function fetchAccountTimeline(id, replace = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchAccountTimelineRequest(id)); dispatch(fetchAccountTimelineRequest(id));
api(getState).get(`/api/v1/accounts/${id}/statuses`).then(response => { const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
dispatch(fetchAccountTimelineSuccess(id, response.data)); const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
}
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
}).catch(error => { }).catch(error => {
dispatch(fetchAccountTimelineFail(id, error)); dispatch(fetchAccountTimelineFail(id, error));
}); });
@@ -86,11 +105,10 @@ export function fetchAccountRequest(id) {
}; };
}; };
export function fetchAccountSuccess(account, relationship) { export function fetchAccountSuccess(account) {
return { return {
type: ACCOUNT_FETCH_SUCCESS, type: ACCOUNT_FETCH_SUCCESS,
account: account, account: account
relationship: relationship
}; };
}; };
@@ -175,11 +193,12 @@ export function fetchAccountTimelineRequest(id) {
}; };
}; };
export function fetchAccountTimelineSuccess(id, statuses) { export function fetchAccountTimelineSuccess(id, statuses, replace) {
return { return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS, type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id: id, id: id,
statuses: statuses statuses: statuses,
replace: replace
}; };
}; };
@@ -279,3 +298,108 @@ export function unblockAccountFail(error) {
error: error error: error
}; };
}; };
export function fetchFollowers(id) {
return (dispatch, getState) => {
dispatch(fetchFollowersRequest(id));
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
dispatch(fetchFollowersSuccess(id, response.data));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFollowersFail(id, error));
});
};
};
export function fetchFollowersRequest(id) {
return {
type: FOLLOWERS_FETCH_REQUEST,
id: id
};
};
export function fetchFollowersSuccess(id, accounts) {
return {
type: FOLLOWERS_FETCH_SUCCESS,
id: id,
accounts: accounts
};
};
export function fetchFollowersFail(id, error) {
return {
type: FOLLOWERS_FETCH_FAIL,
id: id,
error: error
};
};
export function fetchFollowing(id) {
return (dispatch, getState) => {
dispatch(fetchFollowingRequest(id));
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
dispatch(fetchFollowingSuccess(id, response.data));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFollowingFail(id, error));
});
};
};
export function fetchFollowingRequest(id) {
return {
type: FOLLOWING_FETCH_REQUEST,
id: id
};
};
export function fetchFollowingSuccess(id, accounts) {
return {
type: FOLLOWING_FETCH_SUCCESS,
id: id,
accounts: accounts
};
};
export function fetchFollowingFail(id, error) {
return {
type: FOLLOWING_FETCH_FAIL,
id: id,
error: error
};
};
export function fetchRelationships(account_ids) {
return (dispatch, getState) => {
dispatch(fetchRelationshipsRequest(account_ids));
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
dispatch(fetchRelationshipsSuccess(response.data));
}).catch(error => {
dispatch(fetchRelationshipsFail(error));
});
};
};
export function fetchRelationshipsRequest(ids) {
return {
type: RELATIONSHIPS_FETCH_REQUEST,
ids: ids
};
};
export function fetchRelationshipsSuccess(relationships) {
return {
type: RELATIONSHIPS_FETCH_SUCCESS,
relationships: relationships
};
};
export function fetchRelationshipsFail(error) {
return {
type: RELATIONSHIPS_FETCH_FAIL,
error: error
};
};

View File

@@ -1,17 +1,23 @@
import api from '../api' import api from '../api'
import { updateTimeline } 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';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export function changeCompose(text) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@@ -32,6 +38,13 @@ export function cancelReplyCompose() {
}; };
}; };
export function mentionCompose(account) {
return {
type: COMPOSE_MENTION,
account: account
};
};
export function submitCompose() { export function submitCompose() {
return function (dispatch, getState) { return function (dispatch, getState) {
dispatch(submitComposeRequest()); dispatch(submitComposeRequest());
@@ -42,6 +55,7 @@ export function submitCompose() {
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')) media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
}).then(function (response) { }).then(function (response) {
dispatch(submitComposeSuccess(response.data)); dispatch(submitComposeSuccess(response.data));
dispatch(updateTimeline('home', response.data));
}).catch(function (error) { }).catch(function (error) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(error));
}); });
@@ -121,3 +135,27 @@ export function undoUploadCompose(media_id) {
media_id: media_id media_id: media_id
}; };
}; };
export function clearComposeSuggestions() {
return {
type: COMPOSE_SUGGESTIONS_CLEAR
};
};
export function fetchComposeSuggestions(token) {
return (dispatch, getState) => {
const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({
label: item.get('acct'),
completion: item.get('acct').slice(token.length)
})).toList().toJS();
dispatch(readyComposeSuggestions(loadedCandidates));
};
};
export function readyComposeSuggestions(accounts) {
return {
type: COMPOSE_SUGGESTIONS_READY,
accounts
};
};

View File

@@ -16,6 +16,14 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export function reblog(status) { export function reblog(status) {
return function (dispatch, getState) { return function (dispatch, getState) {
dispatch(reblogRequest(status)); dispatch(reblogRequest(status));
@@ -157,3 +165,71 @@ export function unfavouriteFail(status, error) {
error: error error: error
}; };
}; };
export function fetchReblogs(id) {
return (dispatch, getState) => {
dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
dispatch(fetchReblogsSuccess(id, response.data));
}).catch(error => {
dispatch(fetchReblogsFail(id, error));
});
};
};
export function fetchReblogsRequest(id) {
return {
type: REBLOGS_FETCH_REQUEST,
id
};
};
export function fetchReblogsSuccess(id, accounts) {
return {
type: REBLOGS_FETCH_SUCCESS,
id,
accounts
};
};
export function fetchReblogsFail(id, error) {
return {
type: REBLOGS_FETCH_FAIL,
error
};
};
export function fetchFavourites(id) {
return (dispatch, getState) => {
dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
dispatch(fetchFavouritesSuccess(id, response.data));
}).catch(error => {
dispatch(fetchFavouritesFail(id, error));
});
};
};
export function fetchFavouritesRequest(id) {
return {
type: FAVOURITES_FETCH_REQUEST,
id
};
};
export function fetchFavouritesSuccess(id, accounts) {
return {
type: FAVOURITES_FETCH_SUCCESS,
id,
accounts
};
};
export function fetchFavouritesFail(id, error) {
return {
type: FAVOURITES_FETCH_FAIL,
error
};
};

View File

@@ -0,0 +1,15 @@
export const MEDIA_OPEN = 'MEDIA_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE';
export function openMedia(url) {
return {
type: MEDIA_OPEN,
url: url
};
};
export function closeModal() {
return {
type: MODAL_CLOSE
};
};

View File

@@ -1,5 +1,6 @@
import api from '../api'; import api from '../api';
import axios from 'axios';
import { deleteFromTimelines } from './timelines';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -9,6 +10,10 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
export function fetchStatusRequest(id) { export function fetchStatusRequest(id) {
return { return {
type: STATUS_FETCH_REQUEST, type: STATUS_FETCH_REQUEST,
@@ -18,12 +23,11 @@ export function fetchStatusRequest(id) {
export function fetchStatus(id) { export function fetchStatus(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
const boundApi = api(getState);
dispatch(fetchStatusRequest(id)); dispatch(fetchStatusRequest(id));
axios.all([boundApi.get(`/api/v1/statuses/${id}`), boundApi.get(`/api/v1/statuses/${id}/context`)]).then(values => { api(getState).get(`/api/v1/statuses/${id}`).then(response => {
dispatch(fetchStatusSuccess(values[0].data, values[1].data)); dispatch(fetchStatusSuccess(response.data));
dispatch(fetchContext(id));
}).catch(error => { }).catch(error => {
dispatch(fetchStatusFail(id, error)); dispatch(fetchStatusFail(id, error));
}); });
@@ -52,6 +56,7 @@ export function deleteStatus(id) {
api(getState).delete(`/api/v1/statuses/${id}`).then(response => { api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
dispatch(deleteStatusSuccess(id)); dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
}).catch(error => { }).catch(error => {
dispatch(deleteStatusFail(id, error)); dispatch(deleteStatusFail(id, error));
}); });
@@ -79,3 +84,40 @@ export function deleteStatusFail(id, error) {
error: error error: error
}; };
}; };
export function fetchContext(id) {
return (dispatch, getState) => {
dispatch(fetchContextRequest(id));
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
}).catch(error => {
dispatch(fetchContextFail(id, error));
});
};
};
export function fetchContextRequest(id) {
return {
type: CONTEXT_FETCH_REQUEST,
id
};
};
export function fetchContextSuccess(id, ancestors, descendants) {
return {
type: CONTEXT_FETCH_SUCCESS,
id,
ancestors,
descendants,
statuses: ancestors.concat(descendants)
};
};
export function fetchContextFail(id, error) {
return {
type: CONTEXT_FETCH_FAIL,
id,
error
};
};

View File

@@ -22,10 +22,10 @@ export function fetchSuggestionsRequest() {
}; };
}; };
export function fetchSuggestionsSuccess(suggestions) { export function fetchSuggestionsSuccess(accounts) {
return { return {
type: SUGGESTIONS_FETCH_SUCCESS, type: SUGGESTIONS_FETCH_SUCCESS,
suggestions: suggestions accounts: accounts
}; };
}; };

View File

@@ -1,4 +1,5 @@
import api from '../api' import api from '../api'
import Immutable from 'immutable';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@@ -11,26 +12,39 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export function refreshTimelineSuccess(timeline, statuses) { export function refreshTimelineSuccess(timeline, statuses, replace) {
return { return {
type: TIMELINE_REFRESH_SUCCESS, type: TIMELINE_REFRESH_SUCCESS,
timeline: timeline, timeline: timeline,
statuses: statuses statuses: statuses,
replace: replace
}; };
}; };
export function updateTimeline(timeline, status) { export function updateTimeline(timeline, status) {
return { return (dispatch, getState) => {
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
dispatch({
type: TIMELINE_UPDATE, type: TIMELINE_UPDATE,
timeline: timeline, timeline,
status: status status,
references
});
}; };
}; };
export function deleteFromTimelines(id) { export function deleteFromTimelines(id) {
return { return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
dispatch({
type: TIMELINE_DELETE, type: TIMELINE_DELETE,
id: id id,
accountId,
references
});
}; };
}; };
@@ -41,12 +55,26 @@ export function refreshTimelineRequest(timeline) {
}; };
}; };
export function refreshTimeline(timeline) { export function refreshTimeline(timeline, replace = false, id = null) {
return function (dispatch, getState) { return function (dispatch, getState) {
dispatch(refreshTimelineRequest(timeline)); dispatch(refreshTimelineRequest(timeline));
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) { const ids = getState().getIn(['timelines', timeline], Immutable.List());
dispatch(refreshTimelineSuccess(timeline, response.data)); const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let path = timeline;
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
}
if (id) {
path = `${path}/${id}`
}
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
dispatch(refreshTimelineSuccess(timeline, response.data, replace));
}).catch(function (error) { }).catch(function (error) {
dispatch(refreshTimelineFail(timeline, error)); dispatch(refreshTimelineFail(timeline, error));
}); });
@@ -61,13 +89,19 @@ export function refreshTimelineFail(timeline, error) {
}; };
}; };
export function expandTimeline(timeline) { export function expandTimeline(timeline, id = null) {
return (dispatch, getState) => { return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', timeline]).last(); const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last();
dispatch(expandTimelineRequest(timeline)); dispatch(expandTimelineRequest(timeline));
api(getState).get(`/api/v1/statuses/${timeline}?max_id=${lastId}`).then(response => { let path = timeline;
if (id) {
path = `${path}/${id}`
}
api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
dispatch(expandTimelineSuccess(timeline, response.data)); dispatch(expandTimelineSuccess(timeline, response.data));
}).catch(error => { }).catch(error => {
dispatch(expandTimelineFail(timeline, error)); dispatch(expandTimelineFail(timeline, error));

View File

@@ -0,0 +1,40 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
const outerStyle = {
padding: '15px',
fontSize: '16px',
background: '#2f3441',
flex: '0 0 auto',
cursor: 'pointer',
color: '#2b90d9'
};
const iconStyle = {
display: 'inline-block',
marginRight: '5px'
};
const ColumnBackButton = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
mixins: [PureRenderMixin],
handleClick () {
this.context.router.goBack();
},
render () {
return (
<div onClick={this.handleClick} style={outerStyle}>
<i className='fa fa-fw fa-chevron-left' style={iconStyle} />
Back
</div>
);
}
});
export default ColumnBackButton;

View File

@@ -28,16 +28,21 @@ const IconButton = React.createClass({
render () { render () {
const style = { const style = {
display: 'inline-block', display: 'inline-block',
border: 'none',
padding: '0',
background: 'transparent',
fontSize: `${this.props.size}px`, fontSize: `${this.props.size}px`,
width: `${this.props.size}px`, width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size}px`, height: `${this.props.size}px`,
lineHeight: `${this.props.size}px` lineHeight: `${this.props.size}px`,
cursor: 'pointer',
...this.props.style
}; };
return ( return (
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}> <button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
<i className={`fa fa-fw fa-${this.props.icon}`}></i> <i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</a> </button>
); );
} }

View File

@@ -0,0 +1,53 @@
import IconButton from './icon_button';
const overlayStyle = {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
justifyContent: 'center',
alignContent: 'center',
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
zIndex: '9999'
};
const dialogStyle = {
color: '#282c37',
background: '#d9e1e8',
borderRadius: '4px',
boxShadow: '0 0 15px rgba(0, 0, 0, 0.4)',
padding: '10px',
margin: 'auto',
position: 'relative'
};
const closeStyle = {
position: 'absolute',
top: '4px',
right: '4px'
};
const Lightbox = ({ isVisible, onOverlayClicked, onCloseClicked, children }) => {
if (!isVisible) {
return <div />;
}
return (
<div className='lightbox' style={overlayStyle} onClick={onOverlayClicked}>
<div style={dialogStyle}>
<IconButton title='Close' icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
{children}
</div>
</div>
);
};
Lightbox.propTypes = {
isVisible: React.PropTypes.bool,
onOverlayClicked: React.PropTypes.func,
onCloseClicked: React.PropTypes.func
};
export default Lightbox;

View File

@@ -5,11 +5,21 @@ const MediaGallery = React.createClass({
propTypes: { propTypes: {
media: ImmutablePropTypes.list.isRequired, media: ImmutablePropTypes.list.isRequired,
height: React.PropTypes.number.isRequired height: React.PropTypes.number.isRequired,
onOpenMedia: React.PropTypes.func.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
handleClick (url, e) {
if (e.button === 0) {
e.preventDefault();
this.props.onOpenMedia(url);
}
e.stopPropagation();
},
render () { render () {
var children = this.props.media.take(4); var children = this.props.media.take(4);
var size = children.size; var size = children.size;
@@ -64,7 +74,11 @@ const MediaGallery = React.createClass({
} }
} }
return <a key={attachment.get('id')} href={attachment.get('url')} target='_blank' style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', textDecoration: 'none', border: 'none', display: 'block', width: `${width}%`, height: `${height}%`, background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover', cursor: 'zoom-in' }} />; return (
<div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
<a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
</div>
);
}); });
return ( return (

View File

@@ -21,35 +21,28 @@ moment.updateLocale('en', {
const RelativeTimestamp = React.createClass({ const RelativeTimestamp = React.createClass({
getInitialState () {
return {
text: ''
};
},
propTypes: { propTypes: {
timestamp: React.PropTypes.string.isRequired timestamp: React.PropTypes.string.isRequired,
now: React.PropTypes.any
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
componentWillMount () {
this._updateMomentText();
this.interval = setInterval(this._updateMomentText, 60000);
},
componentWillUnmount () {
clearInterval(this.interval);
},
_updateMomentText () {
this.setState({ text: moment(this.props.timestamp).fromNow() });
},
render () { render () {
const timestamp = moment(this.props.timestamp);
const now = this.props.now;
let string = '';
if (timestamp.isAfter(now)) {
string = 'Just now';
} else {
string = timestamp.from(now);
}
return ( return (
<span> <span>
{this.state.text} {string}
</span> </span>
); );
} }

View File

@@ -15,13 +15,15 @@ const Status = React.createClass({
}, },
propTypes: { propTypes: {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map,
wrapped: React.PropTypes.bool, wrapped: React.PropTypes.bool,
onReply: React.PropTypes.func, onReply: React.PropTypes.func,
onFavourite: React.PropTypes.func, onFavourite: React.PropTypes.func,
onReblog: React.PropTypes.func, onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func, onDelete: React.PropTypes.func,
me: React.PropTypes.number onOpenMedia: React.PropTypes.func,
me: React.PropTypes.number,
now: React.PropTypes.any
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@@ -42,7 +44,11 @@ const Status = React.createClass({
render () { render () {
let media = ''; let media = '';
let { status, ...other } = this.props; const { status, now, ...other } = this.props;
if (status === null) {
return <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']); let displayName = status.getIn(['account', 'display_name']);
@@ -67,7 +73,7 @@ const Status = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') { if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />; media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
} else { } else {
media = <MediaGallery media={status.get('media_attachments')} height={110} />; media = <MediaGallery media={status.get('media_attachments')} height={110} onOpenMedia={this.props.onOpenMedia} />;
} }
} }
@@ -75,7 +81,7 @@ const Status = React.createClass({
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}> <div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
<div style={{ fontSize: '15px' }}> <div style={{ fontSize: '15px' }}>
<div style={{ float: 'right', fontSize: '14px' }}> <div style={{ float: 'right', fontSize: '14px' }}>
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} now={now} /></a>
</div> </div>
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}> <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>

View File

@@ -9,7 +9,8 @@ const StatusActionBar = React.createClass({
onReply: React.PropTypes.func, onReply: React.PropTypes.func,
onFavourite: React.PropTypes.func, onFavourite: React.PropTypes.func,
onReblog: React.PropTypes.func, onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func onDelete: React.PropTypes.func,
onMention: React.PropTypes.func
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@@ -30,12 +31,18 @@ const StatusActionBar = React.createClass({
this.props.onDelete(this.props.status); this.props.onDelete(this.props.status);
}, },
handleMentionClick () {
this.props.onMention(this.props.status.get('account'));
},
render () { render () {
const { status, me } = this.props; const { status, me } = this.props;
let menu = []; let menu = [];
if (status.getIn(['account', 'id']) === me) { if (status.getIn(['account', 'id']) === me) {
menu.push({ text: 'Delete', action: this.handleDeleteClick }); menu.push({ text: 'Delete', action: this.handleDeleteClick });
} else {
menu.push({ text: 'Mention', action: this.handleMentionClick });
} }
return ( return (

View File

@@ -1,5 +1,10 @@
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import emojione from 'emojione';
emojione.imageType = 'png';
emojione.sprites = false;
emojione.imagePathPNG = '/emoji/';
const StatusContent = React.createClass({ const StatusContent = React.createClass({
@@ -23,11 +28,14 @@ const StatusContent = React.createClass({
if (mention) { if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false); link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else { } else {
link.setAttribute('target', '_blank'); link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener'); link.setAttribute('rel', 'noopener');
link.addEventListener('click', this.onNormalClick, false);
} }
link.addEventListener('click', this.onNormalClick, false);
} }
}, },
@@ -36,8 +44,15 @@ const StatusContent = React.createClass({
e.preventDefault(); e.preventDefault();
this.context.router.push(`/accounts/${mention.get('id')}`); this.context.router.push(`/accounts/${mention.get('id')}`);
} }
},
e.stopPropagation(); onHashtagClick (hashtag, e) {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/statuses/tag/${hashtag}`);
}
}, },
onNormalClick (e) { onNormalClick (e) {
@@ -45,7 +60,7 @@ const StatusContent = React.createClass({
}, },
render () { render () {
const content = { __html: this.props.status.get('content') }; const content = { __html: emojione.unicodeToImage(this.props.status.get('content')) };
return <div className='status__content' dangerouslySetInnerHTML={content} />; return <div className='status__content' dangerouslySetInnerHTML={content} />;
}, },

View File

@@ -1,41 +1,82 @@
import Status from './status'; import Status from './status';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import { ScrollContainer } from 'react-router-scroll';
import StatusContainer from '../containers/status_container';
import moment from 'moment';
const StatusList = React.createClass({ const StatusList = React.createClass({
propTypes: { propTypes: {
statuses: ImmutablePropTypes.list.isRequired, statusIds: ImmutablePropTypes.list.isRequired,
onReply: React.PropTypes.func,
onReblog: React.PropTypes.func,
onFavourite: React.PropTypes.func,
onDelete: React.PropTypes.func,
onScrollToBottom: React.PropTypes.func, onScrollToBottom: React.PropTypes.func,
me: React.PropTypes.number trackScroll: React.PropTypes.bool
},
getDefaultProps () {
return {
trackScroll: true
};
},
getInitialState () {
return {
now: moment()
};
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
componentDidMount () {
this._interval = setInterval(() => this.setState({ now: moment() }), 60000);
},
componentWillUnmount () {
clearInterval(this._interval);
},
handleScroll (e) { handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target; const { scrollTop, scrollHeight, clientHeight } = e.target;
this._oldScrollPosition = scrollHeight - scrollTop;
if (scrollTop === scrollHeight - clientHeight) { if (scrollTop === scrollHeight - clientHeight) {
this.props.onScrollToBottom(); this.props.onScrollToBottom();
} }
}, },
render () { componentDidUpdate (prevProps) {
const { statuses, onScrollToBottom, ...other } = this.props; if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
const node = ReactDOM.findDOMNode(this);
return ( if (node.scrollTop > 0) {
<div style={{ overflowY: 'scroll', flex: '1 1 auto', overflowX: 'hidden' }} className='scrollable' onScroll={this.handleScroll}> node.scrollTop = node.scrollHeight - this._oldScrollPosition;
}
}
},
render () {
const { statusIds, onScrollToBottom, trackScroll } = this.props;
const scrollableArea = (
<div className='scrollable' onScroll={this.handleScroll}>
<div> <div>
{statuses.map((status) => { {statusIds.map((statusId) => {
return <Status key={status.get('id')} {...other} status={status} />; return <StatusContainer key={statusId} id={statusId} now={this.state.now} />;
})} })}
</div> </div>
</div> </div>
); );
if (trackScroll) {
return (
<ScrollContainer scrollKey='status-list'>
{scrollableArea}
</ScrollContainer>
);
} else {
return scrollableArea;
}
} }
}); });

View File

@@ -2,6 +2,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button'; import IconButton from './icon_button';
const videoStyle = {
position: 'relative',
zIndex: '1',
width: '100%',
height: '100%',
objectFit: 'cover',
top: '50%',
transform: 'translateY(-50%)'
};
const muteStyle = {
position: 'absolute',
top: '10px',
left: '10px',
opacity: '0.8',
zIndex: '5'
};
const VideoPlayer = React.createClass({ const VideoPlayer = React.createClass({
propTypes: { propTypes: {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
@@ -28,11 +46,23 @@ const VideoPlayer = React.createClass({
this.setState({ muted: !this.state.muted }); this.setState({ muted: !this.state.muted });
}, },
handleVideoClick (e) {
e.stopPropagation();
const node = ReactDOM.findDOMNode(this).querySelector('video');
if (node.paused) {
node.play();
} else {
node.pause();
}
},
render () { render () {
return ( return (
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
<div style={{ position: 'absolute', top: '10px', left: '10px', opacity: '0.8' }}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div> <div style={muteStyle}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} /> <video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div> </div>
); );
} }

View File

@@ -10,11 +10,13 @@ import { setAccessToken } from '../actions/meta';
import { setAccountSelf } from '../actions/accounts'; import { setAccountSelf } from '../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import { import {
applyRouterMiddleware,
Router, Router,
Route, Route,
hashHistory, hashHistory,
IndexRoute IndexRoute
} from 'react-router'; } from 'react-router';
import { useScroll } from 'react-router-scroll';
import UI from '../features/ui'; import UI from '../features/ui';
import Account from '../features/account'; import Account from '../features/account';
import Status from '../features/status'; import Status from '../features/status';
@@ -24,6 +26,11 @@ import AccountTimeline from '../features/account_timeline';
import HomeTimeline from '../features/home_timeline'; import HomeTimeline from '../features/home_timeline';
import MentionsTimeline from '../features/mentions_timeline'; import MentionsTimeline from '../features/mentions_timeline';
import Compose from '../features/compose'; import Compose from '../features/compose';
import Followers from '../features/followers';
import Following from '../features/following';
import Reblogs from '../features/reblogs';
import Favourites from '../features/favourites';
import HashtagTimeline from '../features/hashtag_timeline';
const store = configureStore(); const store = configureStore();
@@ -52,9 +59,9 @@ const Mastodon = React.createClass({
return store.dispatch(deleteFromTimelines(data.id)); return store.dispatch(deleteFromTimelines(data.id));
case 'merge': case 'merge':
case 'unmerge': case 'unmerge':
return store.dispatch(refreshTimeline('home')); return store.dispatch(refreshTimeline('home', true));
case 'block': case 'block':
return store.dispatch(refreshTimeline('mentions')); return store.dispatch(refreshTimeline('mentions', true));
} }
} }
@@ -71,16 +78,24 @@ const Mastodon = React.createClass({
render () { render () {
return ( return (
<Provider store={store}> <Provider store={store}>
<Router history={hashHistory}> <Router history={hashHistory} render={applyRouterMiddleware(useScroll())}>
<Route path='/' component={UI}> <Route path='/' component={UI}>
<IndexRoute component={GettingStarted} /> <IndexRoute component={GettingStarted} />
<Route path='/statuses/new' component={Compose} /> <Route path='/statuses/new' component={Compose} />
<Route path='/statuses/home' component={HomeTimeline} /> <Route path='/statuses/home' component={HomeTimeline} />
<Route path='/statuses/mentions' component={MentionsTimeline} /> <Route path='/statuses/mentions' component={MentionsTimeline} />
<Route path='/statuses/all' component={PublicTimeline} /> <Route path='/statuses/all' component={PublicTimeline} />
<Route path='/statuses/tag/:id' component={HashtagTimeline} />
<Route path='/statuses/:statusId' component={Status} /> <Route path='/statuses/:statusId' component={Status} />
<Route path='/statuses/:statusId/reblogs' component={Reblogs} />
<Route path='/statuses/:statusId/favourites' component={Favourites} />
<Route path='/accounts/:accountId' component={Account}> <Route path='/accounts/:accountId' component={Account}>
<IndexRoute component={AccountTimeline} /> <IndexRoute component={AccountTimeline} />
<Route path='/accounts/:accountId/followers' component={Followers} />
<Route path='/accounts/:accountId/following' component={Following} />
</Route> </Route>
</Route> </Route>
</Router> </Router>

View File

@@ -0,0 +1,102 @@
import { connect } from 'react-redux';
import Status from '../components/status';
import { makeGetStatus } from '../selectors';
import {
replyCompose,
mentionCompose
} from '../actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite
} from '../actions/interactions';
import { deleteStatus } from '../actions/statuses';
import { openMedia } from '../actions/modal';
import { createSelector } from 'reselect'
const mapStateToProps = (state, props) => ({
statusBase: state.getIn(['statuses', props.id]),
me: state.getIn(['meta', 'me'])
});
const makeMapStateToPropsInner = () => {
const getStatus = (() => {
return createSelector(
[
(_, base) => base,
(state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null),
(state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null)
],
(base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null)
);
})();
const mapStateToProps = (state, { statusBase }) => ({
status: getStatus(state, statusBase)
});
return mapStateToProps;
};
const makeMapStateToPropsLast = () => {
const getStatus = (() => {
return createSelector(
[
(_, status) => status,
(state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null)
],
(status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status)
);
})();
const mapStateToProps = (state, { status }) => ({
status: getStatus(state, status)
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onReply (status) {
dispatch(replyCompose(status));
},
onReblog (status) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
dispatch(reblog(status));
}
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
},
onMention (account) {
dispatch(mentionCompose(account));
},
onOpenMedia (url) {
dispatch(openMedia(url));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(
connect(makeMapStateToPropsInner)(
connect(makeMapStateToPropsLast)(Status)
)
);

View File

@@ -1,6 +1,27 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenu from '../../../components/dropdown_menu'; import DropdownMenu from '../../../components/dropdown_menu';
import { Link } from 'react-router';
const outerStyle = {
borderTop: '1px solid #363c4b',
borderBottom: '1px solid #363c4b',
lineHeight: '36px',
overflow: 'hidden',
flex: '0 0 auto',
display: 'flex'
};
const outerDropdownStyle = {
padding: '10px',
flex: '1 1 auto'
};
const outerLinksStyle = {
flex: '1 1 auto',
display: 'flex',
lineHeight: '18px'
};
const ActionBar = React.createClass({ const ActionBar = React.createClass({
@@ -8,7 +29,8 @@ const ActionBar = React.createClass({
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired, me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired, onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@@ -18,6 +40,8 @@ const ActionBar = React.createClass({
let menu = []; let menu = [];
menu.push({ text: 'Mention', action: this.props.onMention });
if (account.get('id') === me) { if (account.get('id') === me) {
menu.push({ text: 'Edit profile', href: '/settings/profile' }); menu.push({ text: 'Edit profile', href: '/settings/profile' });
} else if (account.getIn(['relationship', 'blocking'])) { } else if (account.getIn(['relationship', 'blocking'])) {
@@ -31,26 +55,26 @@ const ActionBar = React.createClass({
} }
return ( return (
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}> <div style={outerStyle}>
<div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}> <div style={outerDropdownStyle}>
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}> <DropdownMenu items={menu} icon='bars' size={24} />
</div>
<div style={outerLinksStyle}>
<Link to={`/accounts/${account.get('id')}`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span> <span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span> <span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
</div> </Link>
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px 5px' }}> <Link to={`/accounts/${account.get('id')}/following`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px 5px' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span> <span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span> <span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
</div> </Link>
<div style={{ overflow: 'hidden', width: '80px', padding: '10px 5px', borderRight: '1px solid #363c4b' }}> <Link to={`/accounts/${account.get('id')}/followers`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', padding: '10px 5px', borderLeft: '1px solid #363c4b' }}>
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span> <span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span> <span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
</div> </Link>
</div>
<div style={{ padding: '10px', flex: '1 1 auto' }}>
<DropdownMenu items={menu} icon='bars' size={24} />
</div> </div>
</div> </div>
); );

View File

@@ -24,19 +24,21 @@ const Header = React.createClass({
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span> info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
} }
const content = { __html: account.get('note') };
return ( return (
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}> <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}> <div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '20px 10px' }}>
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}> <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}> <div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} /> <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
</div> </div>
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span> <span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
</a> </a>
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span> <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')}</span>
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p> <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
{info} {info}
</div> </div>

View File

@@ -10,19 +10,27 @@ import {
fetchAccountTimeline, fetchAccountTimeline,
expandAccountTimeline expandAccountTimeline
} from '../../actions/accounts'; } from '../../actions/accounts';
import { mentionCompose } from '../../actions/compose';
import Header from './components/header'; import Header from './components/header';
import { import {
getAccountTimeline, getAccountTimeline,
getAccount makeGetAccount
} from '../../selectors'; } from '../../selectors';
import LoadingIndicator from '../../components/loading_indicator'; import LoadingIndicator from '../../components/loading_indicator';
import ActionBar from './components/action_bar'; import ActionBar from './components/action_bar';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
const mapStateToProps = (state, props) => ({ const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, Number(props.params.accountId)), account: getAccount(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me']) me: state.getIn(['meta', 'me'])
}); });
return mapStateToProps;
};
const Account = React.createClass({ const Account = React.createClass({
@@ -39,7 +47,7 @@ const Account = React.createClass({
this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
}, },
componentWillReceiveProps(nextProps) { componentWillReceiveProps (nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
} }
@@ -61,6 +69,10 @@ const Account = React.createClass({
} }
}, },
handleMention () {
this.props.dispatch(mentionCompose(this.props.account));
},
render () { render () {
const { account, me } = this.props; const { account, me } = this.props;
@@ -74,9 +86,10 @@ const Account = React.createClass({
return ( return (
<Column> <Column>
<ColumnBackButton />
<Header account={account} me={me} /> <Header account={account} me={me} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} /> <ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} onMention={this.handleMention} />
{this.props.children} {this.props.children}
</Column> </Column>
@@ -85,4 +98,4 @@ const Account = React.createClass({
}); });
export default connect(mapStateToProps)(Account); export default connect(makeMapStateToProps)(Account);

View File

@@ -1,24 +1,16 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { getAccountTimeline } from '../../selectors';
import { import {
fetchAccountTimeline, fetchAccountTimeline,
expandAccountTimeline expandAccountTimeline
} from '../../actions/accounts'; } from '../../actions/accounts';
import { deleteStatus } from '../../actions/statuses';
import { replyCompose } from '../../actions/compose';
import {
favourite,
reblog,
unreblog,
unfavourite
} from '../../actions/interactions';
import StatusList from '../../components/status_list'; import StatusList from '../../components/status_list';
import LoadingIndicator from '../../components/loading_indicator';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
statuses: getAccountTimeline(state, Number(props.params.accountId)), statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
me: state.getIn(['timelines', 'me']) me: state.getIn(['meta', 'me'])
}); });
const AccountTimeline = React.createClass({ const AccountTimeline = React.createClass({
@@ -26,7 +18,7 @@ const AccountTimeline = React.createClass({
propTypes: { propTypes: {
params: React.PropTypes.object.isRequired, params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired, dispatch: React.PropTypes.func.isRequired,
statuses: ImmutablePropTypes.list statusIds: ImmutablePropTypes.list
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@@ -41,38 +33,18 @@ const AccountTimeline = React.createClass({
} }
}, },
handleReply (status) {
this.props.dispatch(replyCompose(status));
},
handleReblog (status) {
if (status.get('reblogged')) {
this.props.dispatch(unreblog(status));
} else {
this.props.dispatch(reblog(status));
}
},
handleFavourite (status) {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
} else {
this.props.dispatch(favourite(status));
}
},
handleDelete (status) {
this.props.dispatch(deleteStatus(status.get('id')));
},
handleScrollToBottom () { handleScrollToBottom () {
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId))); this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
}, },
render () { render () {
const { statuses, me } = this.props; const { statusIds, me } = this.props;
return <StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} onDelete={this.handleDelete} /> if (!statusIds) {
return <LoadingIndicator />;
}
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
} }
}); });

View File

@@ -1,12 +1,8 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar'; import AccountContainer from '../../followers/containers/account_container';
import DisplayName from '../../../components/display_name';
import { Link } from 'react-router';
const outerStyle = { const outerStyle = {
marginBottom: '10px',
borderTop: '1px solid #616b86',
position: 'relative' position: 'relative'
}; };
@@ -17,32 +13,12 @@ const headerStyle = {
padding: '10px', padding: '10px',
color: '#9baec8', color: '#9baec8',
background: '#454b5e', background: '#454b5e',
width: '120px', overflow: 'hidden'
marginTop: '-18px'
};
const itemStyle = {
display: 'block',
padding: '10px',
color: '#9baec8',
overflow: 'hidden',
textDecoration: 'none'
};
const displayNameStyle = {
display: 'block',
fontWeight: '500',
overflow: 'hidden',
textOverflow: 'ellipsis'
};
const acctStyle = {
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis'
}; };
const nextStyle = { const nextStyle = {
display: 'inline-block',
float: 'right',
fontWeight: '400', fontWeight: '400',
color: '#2b90d9' color: '#2b90d9'
}; };
@@ -50,7 +26,7 @@ const nextStyle = {
const SuggestionsBox = React.createClass({ const SuggestionsBox = React.createClass({
propTypes: { propTypes: {
accounts: ImmutablePropTypes.list.isRequired, accountIds: ImmutablePropTypes.list,
perWindow: React.PropTypes.number perWindow: React.PropTypes.number
}, },
@@ -73,7 +49,7 @@ const SuggestionsBox = React.createClass({
let newIndex = this.state.index + 1; let newIndex = this.state.index + 1;
if (this.props.accounts.skip(this.props.perWindow * newIndex).size === 0) { if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) {
newIndex = 0; newIndex = 0;
} }
@@ -81,16 +57,16 @@ const SuggestionsBox = React.createClass({
}, },
render () { render () {
const { accounts, perWindow } = this.props; const { accountIds, perWindow } = this.props;
if (accounts.size === 0) { if (!accountIds || accountIds.size === 0) {
return <div />; return <div />;
} }
let nextLink = ''; let nextLink = '';
if (accounts.size > perWindow) { if (accountIds.size > perWindow) {
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Next</a>; nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Refresh</a>;
} }
return ( return (
@@ -99,21 +75,7 @@ const SuggestionsBox = React.createClass({
Who to follow {nextLink} Who to follow {nextLink}
</strong> </strong>
{accounts.skip(perWindow * this.state.index).take(perWindow).map(account => { {accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)}
let displayName = account.get('display_name');
if (displayName.length === 0) {
displayName = account.get('username');
}
return (
<Link key={account.get('id')} style={itemStyle} to={`/accounts/${account.get('id')}`}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
<strong style={displayNameStyle}>{displayName}</strong>
<span style={acctStyle}>{account.get('acct')}</span>
</Link>
)
})}
</div> </div>
); );
} }

View File

@@ -1,9 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getSuggestions } from '../../../selectors';
import SuggestionsBox from '../components/suggestions_box'; import SuggestionsBox from '../components/suggestions_box';
const mapStateToProps = (state) => ({ const mapStateToProps = (state) => ({
accounts: getSuggestions(state) accountIds: state.getIn(['user_lists', 'suggestions'])
}); });
export default connect(mapStateToProps)(SuggestionsBox); export default connect(mapStateToProps)(SuggestionsBox);

View File

@@ -0,0 +1,61 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavourites } from '../../actions/interactions';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../followers/containers/account_container';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)])
});
const Favourites = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='favourites'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(Favourites);

View File

@@ -0,0 +1,94 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { Link } from 'react-router';
import IconButton from '../../../components/icon_button';
const outerStyle = {
padding: '10px',
borderBottom: '1px solid #363c4b'
};
const itemStyle = {
flex: '1 1 auto',
display: 'block',
color: '#9baec8',
overflow: 'hidden',
textDecoration: 'none',
fontSize: '14px'
};
const noteStyle = {
paddingTop: '5px',
fontSize: '12px',
color: '#616b86'
};
const buttonsStyle = {
padding: '10px'
};
const Account = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
withNote: React.PropTypes.bool
},
getDefaultProps () {
return {
withNote: true
};
},
mixins: [PureRenderMixin],
handleFollow () {
this.props.onFollow(this.props.account);
},
render () {
const { account, me, withNote } = this.props;
if (!account) {
return <div />;
}
let note, buttons;
if (account.get('note').length > 0 && withNote) {
note = <div style={noteStyle}>{account.get('note')}</div>;
}
if (account.get('id') !== me) {
const following = account.getIn(['relationship', 'following']);
buttons = (
<div style={buttonsStyle}>
<IconButton icon={following ? 'user-times' : 'user-plus'} title='Follow' onClick={this.handleFollow} active={following} />
</div>
);
}
return (
<div style={outerStyle}>
<div style={{ display: 'flex' }}>
<Link key={account.get('id')} style={itemStyle} className='account__display-name' to={`/accounts/${account.get('id')}`}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
<DisplayName account={account} />
</Link>
{buttons}
</div>
{note}
</div>
);
}
});
export default Account;

View File

@@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import Account from '../components/account';
import {
followAccount,
unfollowAccount
} from '../../../actions/accounts';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following'])) {
dispatch(unfollowAccount(account.get('id')));
} else {
dispatch(followAccount(account.get('id')));
}
}
});
export default connect(makeMapStateToProps, mapDispatchToProps)(Account);

View File

@@ -0,0 +1,51 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFollowers } from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from './containers/account_container';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId)])
});
const Followers = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return <LoadingIndicator />;
}
return (
<ScrollContainer scrollKey='followers'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
);
}
});
export default connect(mapStateToProps)(Followers);

View File

@@ -0,0 +1,51 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFollowing } from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../followers/containers/account_container';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId)])
});
const Following = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return <LoadingIndicator />;
}
return (
<ScrollContainer scrollKey='following'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
);
}
});
export default connect(mapStateToProps)(Following);

View File

@@ -6,7 +6,6 @@ const GettingStarted = () => {
<Column> <Column>
<div className='static-content'> <div className='static-content'>
<h1>Getting started</h1> <h1>Getting started</h1>
<p>Mastodon is still in development and one of the lacking areas at the moment is user discovery.</p>
<p>You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.</p> <p>You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.</p>
<p>If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.</p> <p>If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.</p>
<p>The developer of this project can be followed as Gargron@mastodon.social</p> <p>The developer of this project can be followed as Gargron@mastodon.social</p>

View File

@@ -0,0 +1,78 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../ui/components/column';
import {
refreshTimeline,
updateTimeline,
deleteFromTimelines
} from '../../actions/timelines';
const HashtagTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
_subscribe (dispatch, id) {
if (typeof App !== 'undefined') {
this.subscription = App.cable.subscriptions.create({
channel: 'HashtagChannel',
tag: id
}, {
received (data) {
switch(data.type) {
case 'update':
return dispatch(updateTimeline('tag', JSON.parse(data.message)));
case 'delete':
return dispatch(deleteFromTimelines(data.id));
}
}
});
}
},
_unsubscribe () {
if (typeof this.subscription !== 'undefined') {
this.subscription.unsubscribe();
}
},
componentWillMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(refreshTimeline('tag', true, id));
this._subscribe(dispatch, id);
},
componentWillReceiveProps (nextProps) {
if (nextProps.params.id !== this.props.params.id) {
this.props.dispatch(refreshTimeline('tag', true, nextProps.params.id));
this._unsubscribe();
this._subscribe(this.props.dispatch, nextProps.params.id);
}
},
componentWillUnmount () {
this._unsubscribe();
},
render () {
const { id } = this.props.params;
return (
<Column icon='hashtag' heading={id}>
<StatusListContainer type='tag' id={id} />
</Column>
);
},
});
export default connect()(HashtagTimeline);

View File

@@ -19,7 +19,7 @@ const HomeTimeline = React.createClass({
render () { render () {
return ( return (
<Column icon='home' heading='Home'> <Column icon='home' heading='Home'>
<StatusListContainer type='home' /> <StatusListContainer {...this.props} type='home' />
</Column> </Column>
); );
}, },

View File

@@ -19,7 +19,7 @@ const MentionsTimeline = React.createClass({
render () { render () {
return ( return (
<Column icon='at' heading='Mentions'> <Column icon='at' heading='Mentions'>
<StatusListContainer type='mentions' /> <StatusListContainer {...this.props} type='mentions' />
</Column> </Column>
); );
}, },

View File

@@ -4,7 +4,8 @@ import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import { import {
refreshTimeline, refreshTimeline,
updateTimeline updateTimeline,
deleteFromTimelines
} from '../../actions/timelines'; } from '../../actions/timelines';
const PublicTimeline = React.createClass({ const PublicTimeline = React.createClass({
@@ -24,7 +25,12 @@ const PublicTimeline = React.createClass({
this.subscription = App.cable.subscriptions.create('PublicChannel', { this.subscription = App.cable.subscriptions.create('PublicChannel', {
received (data) { received (data) {
dispatch(updateTimeline('public', JSON.parse(data.message))); switch(data.type) {
case 'update':
return dispatch(updateTimeline('public', JSON.parse(data.message)));
case 'delete':
return dispatch(deleteFromTimelines(data.id));
}
} }
}); });

View File

@@ -0,0 +1,61 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchReblogs } from '../../actions/interactions';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../followers/containers/account_container';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)])
});
const Reblogs = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
componentWillMount () {
this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
},
componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
}
},
render () {
const { accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='reblogs'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
</Column>
);
}
});
export default connect(mapStateToProps)(Reblogs);

View File

@@ -11,6 +11,7 @@ const ActionBar = React.createClass({
onReblog: React.PropTypes.func.isRequired, onReblog: React.PropTypes.func.isRequired,
onFavourite: React.PropTypes.func.isRequired, onFavourite: React.PropTypes.func.isRequired,
onDelete: React.PropTypes.func.isRequired, onDelete: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired,
me: React.PropTypes.number.isRequired me: React.PropTypes.number.isRequired
}, },
@@ -23,6 +24,8 @@ const ActionBar = React.createClass({
if (me === status.getIn(['account', 'id'])) { if (me === status.getIn(['account', 'id'])) {
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) }); menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
} else {
menu.push({ text: 'Mention', action: () => this.props.onMention(status.get('account')) });
} }
return ( return (

View File

@@ -6,6 +6,7 @@ import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery'; import MediaGallery from '../../../components/media_gallery';
import VideoPlayer from '../../../components/video_player'; import VideoPlayer from '../../../components/video_player';
import moment from 'moment'; import moment from 'moment';
import { Link } from 'react-router';
const DetailedStatus = React.createClass({ const DetailedStatus = React.createClass({
@@ -14,7 +15,8 @@ const DetailedStatus = React.createClass({
}, },
propTypes: { propTypes: {
status: ImmutablePropTypes.map.isRequired status: ImmutablePropTypes.map.isRequired,
onOpenMedia: React.PropTypes.func.isRequired
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@@ -36,7 +38,7 @@ const DetailedStatus = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') { if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />; media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
} else { } else {
media = <MediaGallery media={status.get('media_attachments')} height={300} />; media = <MediaGallery media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
} }
} }
@@ -52,7 +54,7 @@ const DetailedStatus = React.createClass({
{media} {media}
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}> <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span> · <i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span> <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span></Link>
</div> </div>
</div> </div>
); );

View File

@@ -9,20 +9,33 @@ import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar'; import ActionBar from './components/action_bar';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import { favourite, reblog } from '../../actions/interactions'; import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose'; import {
replyCompose,
mentionCompose
} from '../../actions/compose';
import { deleteStatus } from '../../actions/statuses'; import { deleteStatus } from '../../actions/statuses';
import { import {
getStatus, makeGetStatus,
getStatusAncestors, getStatusAncestors,
getStatusDescendants getStatusDescendants
} from '../../selectors'; } from '../../selectors';
import { ScrollContainer } from 'react-router-scroll';
import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../containers/status_container';
import { openMedia } from '../../actions/modal';
const mapStateToProps = (state, props) => ({ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({
status: getStatus(state, Number(props.params.statusId)), status: getStatus(state, Number(props.params.statusId)),
ancestors: getStatusAncestors(state, Number(props.params.statusId)), ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
descendants: getStatusDescendants(state, Number(props.params.statusId)), descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
me: state.getIn(['timelines', 'me']) me: state.getIn(['meta', 'me'])
}); });
return mapStateToProps;
};
const Status = React.createClass({ const Status = React.createClass({
@@ -30,8 +43,8 @@ const Status = React.createClass({
params: React.PropTypes.object.isRequired, params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired, dispatch: React.PropTypes.func.isRequired,
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
ancestors: ImmutablePropTypes.orderedSet.isRequired, ancestorsIds: ImmutablePropTypes.list,
descendants: ImmutablePropTypes.orderedSet.isRequired descendantsIds: ImmutablePropTypes.list
}, },
mixins: [PureRenderMixin], mixins: [PureRenderMixin],
@@ -62,12 +75,21 @@ const Status = React.createClass({
this.props.dispatch(deleteStatus(status.get('id'))); this.props.dispatch(deleteStatus(status.get('id')));
}, },
handleMentionClick (account) {
this.props.dispatch(mentionCompose(account));
},
handleOpenMedia (url) {
this.props.dispatch(openMedia(url));
},
renderChildren (list) { renderChildren (list) {
return list.map(s => <EmbeddedStatus status={s} me={this.props.me} key={s.get('id')} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />); return list.map(id => <StatusContainer key={id} id={id} />);
}, },
render () { render () {
const { status, ancestors, descendants, me } = this.props; let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, me } = this.props;
if (status === null) { if (status === null) {
return ( return (
@@ -79,20 +101,32 @@ const Status = React.createClass({
const account = status.get('account'); const account = status.get('account');
if (ancestorsIds && ancestorsIds.size > 0) {
ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
}
if (descendantsIds && descendantsIds.size > 0) {
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
}
return ( return (
<Column> <Column>
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'> <ColumnBackButton />
<div>{this.renderChildren(ancestors)}</div>
<DetailedStatus status={status} me={me} /> <ScrollContainer scrollKey='thread'>
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} /> <div className='scrollable'>
{ancestors}
<div>{this.renderChildren(descendants)}</div> <DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} />
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} />
{descendants}
</div> </div>
</ScrollContainer>
</Column> </Column>
); );
} }
}); });
export default connect(mapStateToProps)(Status); export default connect(makeMapStateToProps)(Status);

View File

@@ -4,11 +4,62 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ReplyIndicator from './reply_indicator'; import ReplyIndicator from './reply_indicator';
import UploadButton from './upload_button'; import UploadButton from './upload_button';
import Autosuggest from 'react-autosuggest';
const getTokenForSuggestions = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 2 || word[0] !== '@') {
return null;
}
word = word.trim().toLowerCase().slice(1);
if (word.length > 0) {
return word;
} else {
return null;
}
};
const getSuggestionValue = suggestion => suggestion.completion;
const renderSuggestion = suggestion => (
<span>{suggestion.label}</span>
);
const textareaStyle = {
display: 'block',
boxSizing: 'border-box',
width: '100%',
height: '100px',
resize: 'none',
border: 'none',
color: '#282c37',
padding: '10px',
fontFamily: 'Roboto',
fontSize: '14px',
margin: '0'
};
const renderInputComponent = inputProps => (
<textarea {...inputProps} placeholder='What is on your mind?' className='compose-form__textarea' style={textareaStyle} />
);
const ComposeForm = React.createClass({ const ComposeForm = React.createClass({
propTypes: { propTypes: {
text: React.PropTypes.string.isRequired, text: React.PropTypes.string.isRequired,
suggestions: React.PropTypes.array,
is_submitting: React.PropTypes.bool, is_submitting: React.PropTypes.bool,
is_uploading: React.PropTypes.bool, is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map, in_reply_to: ImmutablePropTypes.map,
@@ -35,7 +86,39 @@ const ComposeForm = React.createClass({
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) { if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
this.refs.textarea.focus(); const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
textarea.focus();
}
}
},
onSuggestionsClearRequested () {
this.props.onClearSuggestions();
},
onSuggestionsFetchRequested ({ value }) {
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
const token = getTokenForSuggestions(value, textarea.selectionStart);
if (token !== null) {
this.props.onFetchSuggestions(token);
}
}
},
onSuggestionSelected (e, { suggestionValue, method }) {
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
const textarea = node.querySelector('textarea');
if (textarea) {
const str = this.props.text;
this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join(''));
} }
}, },
@@ -47,11 +130,29 @@ const ComposeForm = React.createClass({
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
} }
const inputProps = {
placeholder: 'What is on your mind?',
value: this.props.text,
onKeyUp: this.handleKeyUp,
onChange: this.handleChange,
disabled: disabled
};
return ( return (
<div style={{ padding: '10px' }}> <div style={{ padding: '10px' }}>
{replyArea} {replyArea}
<textarea ref='textarea' disabled={disabled} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} /> <Autosuggest
ref='autosuggest'
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
renderInputComponent={renderInputComponent}
inputProps={inputProps}
/>
<div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div> <div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>

View File

@@ -24,7 +24,7 @@ const UploadButton = React.createClass({
return ( return (
<div> <div>
<Button disabled={this.props.disabled} onClick={this.handleClick} block={true}> <Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
<i className='fa fa-fw fa-photo' /> Add images <i className='fa fa-fw fa-photo' /> Add media
</Button> </Button>
<input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} /> <input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />

View File

@@ -1,31 +1,52 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form'; import ComposeForm from '../components/compose_form';
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose'; import {
import { getStatus } from '../../../selectors'; changeCompose,
submitCompose,
cancelReplyCompose,
clearComposeSuggestions,
fetchComposeSuggestions
} from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
const mapStateToProps = function (state, props) { const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = function (state, props) {
return { return {
text: state.getIn(['compose', 'text']), text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
is_submitting: state.getIn(['compose', 'is_submitting']), is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']), is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])) in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
}; };
};
return mapStateToProps;
}; };
const mapDispatchToProps = function (dispatch) { const mapDispatchToProps = function (dispatch) {
return { return {
onChange: function (text) { onChange (text) {
dispatch(changeCompose(text)); dispatch(changeCompose(text));
}, },
onSubmit: function () { onSubmit () {
dispatch(submitCompose()); dispatch(submitCompose());
}, },
onCancelReply: function () { onCancelReply () {
dispatch(cancelReplyCompose()); dispatch(cancelReplyCompose());
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
} }
} }
}; };
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);

View File

@@ -0,0 +1,48 @@
import { connect } from 'react-redux';
import { closeModal } from '../../../actions/modal';
import Lightbox from '../../../components/lightbox';
const mapStateToProps = state => ({
url: state.getIn(['modal', 'url']),
isVisible: state.getIn(['modal', 'open'])
});
const mapDispatchToProps = dispatch => ({
onCloseClicked () {
dispatch(closeModal());
},
onOverlayClicked () {
dispatch(closeModal());
}
});
const imageStyle = {
display: 'block',
maxWidth: '100%',
height: 'auto',
margin: '0 auto'
};
const Modal = React.createClass({
propTypes: {
url: React.PropTypes.string,
isVisible: React.PropTypes.bool,
onCloseClicked: React.PropTypes.func,
onOverlayClicked: React.PropTypes.func
},
render () {
const { url, ...other } = this.props;
return (
<Lightbox {...other}>
<img src={url} style={imageStyle} />
</Lightbox>
);
}
});
export default connect(mapStateToProps, mapDispatchToProps)(Modal);

View File

@@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar'; import NavigationBar from '../components/navigation_bar';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
account: state.getIn(['timelines', 'accounts', state.getIn(['timelines', 'me'])]) account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
}); });
export default connect(mapStateToProps)(NavigationBar); export default connect(mapStateToProps)(NavigationBar);

View File

@@ -1,57 +1,18 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import StatusList from '../../../components/status_list'; import StatusList from '../../../components/status_list';
import { replyCompose } from '../../../actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite
} from '../../../actions/interactions';
import { expandTimeline } from '../../../actions/timelines'; import { expandTimeline } from '../../../actions/timelines';
import { makeGetTimeline } from '../../../selectors'; import Immutable from 'immutable';
import { deleteStatus } from '../../../actions/statuses';
const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({
const getTimeline = makeGetTimeline(); statusIds: state.getIn(['timelines', props.type], Immutable.List())
});
const mapStateToProps = (state, props) => ({
statuses: getTimeline(state, props.type),
me: state.getIn(['timelines', 'me'])
});
return mapStateToProps;
};
const mapDispatchToProps = function (dispatch, props) { const mapDispatchToProps = function (dispatch, props) {
return { return {
onReply (status) {
dispatch(replyCompose(status));
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onReblog (status) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
dispatch(reblog(status));
}
},
onScrollToBottom () { onScrollToBottom () {
dispatch(expandTimeline(props.type)); dispatch(expandTimeline(props.type, props.id));
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
} }
}; };
}; };
export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); export default connect(mapStateToProps, mapDispatchToProps)(StatusList);

View File

@@ -7,6 +7,7 @@ import MentionsTimeline from '../mentions_timeline';
import Compose from '../compose'; import Compose from '../compose';
import MediaQuery from 'react-responsive'; import MediaQuery from 'react-responsive';
import TabsBar from './components/tabs_bar'; import TabsBar from './components/tabs_bar';
import ModalContainer from './containers/modal_container';
const UI = React.createClass({ const UI = React.createClass({
@@ -28,14 +29,15 @@ const UI = React.createClass({
<MediaQuery minWidth={layoutBreakpoint}> <MediaQuery minWidth={layoutBreakpoint}>
<ColumnsArea> <ColumnsArea>
<Compose /> <Compose />
<HomeTimeline /> <HomeTimeline trackScroll={false} />
<MentionsTimeline /> <MentionsTimeline trackScroll={false} />
{this.props.children} {this.props.children}
</ColumnsArea> </ColumnsArea>
</MediaQuery> </MediaQuery>
<NotificationsContainer /> <NotificationsContainer />
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} /> <LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
<ModalContainer />
</div> </div>
); );
} }

View File

@@ -0,0 +1,89 @@
import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS,
FOLLOWERS_FETCH_SUCCESS,
FOLLOWING_FETCH_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts';
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import {
REBLOG_SUCCESS,
UNREBLOG_SUCCESS,
FAVOURITE_SUCCESS,
UNFAVOURITE_SUCCESS,
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS
} from '../actions/interactions';
import {
TIMELINE_REFRESH_SUCCESS,
TIMELINE_UPDATE,
TIMELINE_EXPAND_SUCCESS
} from '../actions/timelines';
import {
STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import Immutable from 'immutable';
const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
const normalizeAccounts = (state, accounts) => {
accounts.forEach(account => {
state = normalizeAccount(state, account);
});
return state;
};
const normalizeAccountFromStatus = (state, status) => {
state = normalizeAccount(state, status.account);
if (status.reblog && status.reblog.account) {
state = normalizeAccount(state, status.reblog.account);
}
return state;
};
const normalizeAccountsFromStatuses = (state, statuses) => {
statuses.forEach(status => {
state = normalizeAccountFromStatus(state, status);
});
return state;
};
const initialState = Immutable.Map();
export default function accounts(state = initialState, action) {
switch(action.type) {
case ACCOUNT_SET_SELF:
case ACCOUNT_FETCH_SUCCESS:
case FOLLOW_SUBMIT_SUCCESS:
return normalizeAccount(state, action.account);
case SUGGESTIONS_FETCH_SUCCESS:
case FOLLOWERS_FETCH_SUCCESS:
case FOLLOWING_FETCH_SUCCESS:
case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS:
return normalizeAccounts(state, action.accounts);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
return normalizeAccountsFromStatuses(state, action.statuses);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeAccountFromStatus(state, action.response);
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeAccountFromStatus(state, action.status);
default:
return state;
}
};

View File

@@ -2,6 +2,7 @@ import {
COMPOSE_CHANGE, COMPOSE_CHANGE,
COMPOSE_REPLY, COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL, COMPOSE_REPLY_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST, COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS, COMPOSE_SUBMIT_SUCCESS,
COMPOSE_SUBMIT_FAIL, COMPOSE_SUBMIT_FAIL,
@@ -9,7 +10,9 @@ import {
COMPOSE_UPLOAD_SUCCESS, COMPOSE_UPLOAD_SUCCESS,
COMPOSE_UPLOAD_FAIL, COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO, COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS COMPOSE_UPLOAD_PROGRESS,
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY
} from '../actions/compose'; } from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { ACCOUNT_SET_SELF } from '../actions/accounts'; import { ACCOUNT_SET_SELF } from '../actions/accounts';
@@ -21,7 +24,8 @@ const initialState = Immutable.Map({
is_submitting: false, is_submitting: false,
is_uploading: false, is_uploading: false,
progress: 0, progress: 0,
media_attachments: Immutable.List([]), media_attachments: Immutable.List(),
suggestions: [],
me: null me: null
}); });
@@ -92,6 +96,12 @@ export default function compose(state = initialState, action) {
return removeMedia(state, action.media_id); return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS: case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100)); return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_SUGGESTIONS_CLEAR:
return state.set('suggestions', []);
case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', action.accounts);
case TIMELINE_DELETE: case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) { if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null); return state.set('in_reply_to', null);

View File

@@ -5,6 +5,11 @@ import compose from './compose';
import follow from './follow'; import follow from './follow';
import notifications from './notifications'; import notifications from './notifications';
import { loadingBarReducer } from 'react-redux-loading-bar'; import { loadingBarReducer } from 'react-redux-loading-bar';
import modal from './modal';
import user_lists from './user_lists';
import accounts from './accounts';
import statuses from './statuses';
import relationships from './relationships';
export default combineReducers({ export default combineReducers({
timelines, timelines,
@@ -13,4 +18,9 @@ export default combineReducers({
follow, follow,
notifications, notifications,
loadingBar: loadingBarReducer, loadingBar: loadingBarReducer,
modal,
user_lists,
accounts,
statuses,
relationships
}); });

View File

@@ -1,4 +1,5 @@
import { ACCESS_TOKEN_SET } from '../actions/meta'; import { ACCESS_TOKEN_SET } from '../actions/meta';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map(); const initialState = Immutable.Map();
@@ -7,6 +8,8 @@ export default function meta(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ACCESS_TOKEN_SET: case ACCESS_TOKEN_SET:
return state.set('access_token', action.token); return state.set('access_token', action.token);
case ACCOUNT_SET_SELF:
return state.set('me', action.account.id);
default: default:
return state; return state;
} }

View File

@@ -0,0 +1,21 @@
import { MEDIA_OPEN, MODAL_CLOSE } from '../actions/modal';
import Immutable from 'immutable';
const initialState = Immutable.Map({
url: '',
open: false
});
export default function modal(state = initialState, action) {
switch(action.type) {
case MEDIA_OPEN:
return state.withMutations(map => {
map.set('url', action.url);
map.set('open', true);
});
case MODAL_CLOSE:
return state.set('open', false);
default:
return state;
}
};

View File

@@ -0,0 +1,34 @@
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS
} from '../actions/accounts';
import Immutable from 'immutable';
const normalizeRelationship = (state, relationship) => state.set(relationship.id, Immutable.fromJS(relationship));
const normalizeRelationships = (state, relationships) => {
relationships.forEach(relationship => {
state = normalizeRelationship(state, relationship);
});
return state;
};
const initialState = Immutable.Map();
export default function relationships(state = initialState, action) {
switch(action.type) {
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS:
return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
default:
return state;
}
};

View File

@@ -0,0 +1,73 @@
import {
REBLOG_SUCCESS,
UNREBLOG_SUCCESS,
FAVOURITE_SUCCESS,
UNFAVOURITE_SUCCESS
} from '../actions/interactions';
import {
STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import {
TIMELINE_REFRESH_SUCCESS,
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_EXPAND_SUCCESS
} from '../actions/timelines';
import {
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts';
import Immutable from 'immutable';
const normalizeStatus = (state, status) => {
status.account = status.account.id;
if (status.reblog && status.reblog.id) {
state = normalizeStatus(state, status.reblog);
status.reblog = status.reblog.id;
}
return state.set(status.id, Immutable.fromJS(status));
};
const normalizeStatuses = (state, statuses) => {
statuses.forEach(status => {
state = normalizeStatus(state, status);
});
return state;
};
const deleteStatus = (state, id, references) => {
references.forEach(ref => {
state = deleteStatus(state, ref[0], []);
});
return state.delete(id);
};
const initialState = Immutable.Map();
export default function statuses(state = initialState, action) {
switch(action.type) {
case TIMELINE_UPDATE:
case STATUS_FETCH_SUCCESS:
return normalizeStatus(state, action.status);
case REBLOG_SUCCESS:
case UNREBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, action.response);
case TIMELINE_REFRESH_SUCCESS:
case TIMELINE_EXPAND_SUCCESS:
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
case CONTEXT_FETCH_SUCCESS:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
default:
return state;
}
};

View File

@@ -11,108 +11,77 @@ import {
UNFAVOURITE_SUCCESS UNFAVOURITE_SUCCESS
} from '../actions/interactions'; } from '../actions/interactions';
import { import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS, ACCOUNT_FETCH_SUCCESS,
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts'; } from '../actions/accounts';
import { import {
STATUS_FETCH_SUCCESS, STATUS_FETCH_SUCCESS,
STATUS_DELETE_SUCCESS CONTEXT_FETCH_SUCCESS
} from '../actions/statuses'; } from '../actions/statuses';
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import Immutable from 'immutable'; import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
home: Immutable.List([]), home: Immutable.List(),
mentions: Immutable.List([]), mentions: Immutable.List(),
public: Immutable.List([]), public: Immutable.List(),
statuses: Immutable.Map(), tag: Immutable.List(),
accounts: Immutable.Map(),
accounts_timelines: Immutable.Map(), accounts_timelines: Immutable.Map(),
me: null,
ancestors: Immutable.Map(), ancestors: Immutable.Map(),
descendants: Immutable.Map(), descendants: Immutable.Map()
relationships: Immutable.Map(),
suggestions: Immutable.List([])
}); });
function normalizeStatus(state, status) { const normalizeStatus = (state, status) => {
// Separate account const replyToId = status.get('in_reply_to_id');
let account = status.get('account'); const id = status.get('id');
status = status.set('account', account.get('id'));
// Separate reblog, repeat for reblog if (replyToId) {
let reblog = status.get('reblog', null); if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
if (reblog !== null) {
status = status.set('reblog', reblog.get('id'));
state = normalizeStatus(state, reblog);
} }
// Replies if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
if (status.get('in_reply_to_id')) { state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
state = state.updateIn(['descendants', status.get('in_reply_to_id')], set => {
if (!Immutable.OrderedSet.isOrderedSet(set)) {
return Immutable.OrderedSet([status.get('id')]);
} else {
return set.add(status.get('id'));
} }
});
} }
return state.withMutations(map => { return state;
if (status.get('in_reply_to_id')) {
map.updateIn(['descendants', status.get('in_reply_to_id')], Immutable.OrderedSet(), set => set.add(status.get('id')));
map.updateIn(['ancestors', status.get('id')], Immutable.OrderedSet(), set => set.add(status.get('in_reply_to_id')));
}
map.setIn(['accounts', account.get('id')], account);
map.setIn(['statuses', status.get('id')], status);
});
}; };
function normalizeTimeline(state, timeline, statuses) { const normalizeTimeline = (state, timeline, statuses, replace = false) => {
let ids = Immutable.List([]); let ids = Immutable.List();
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
ids = ids.set(i, status.get('id')); ids = ids.set(i, status.get('id'));
}); });
return state.set(timeline, ids); return state.update(timeline, Immutable.List(), list => (replace ? ids : list.unshift(...ids)));
}; };
function appendNormalizedTimeline(state, timeline, statuses) { const appendNormalizedTimeline = (state, timeline, statuses) => {
let moreIds = Immutable.List([]); let moreIds = Immutable.List();
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
moreIds = moreIds.set(i, status.get('id')); moreIds = moreIds.set(i, status.get('id'));
}); });
return state.update(timeline, list => list.push(...moreIds)); return state.update(timeline, Immutable.List(), list => list.push(...moreIds));
}; };
function normalizeAccountTimeline(state, accountId, statuses) { const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => { let ids = Immutable.List();
return (list.size > 0) ? list.clear() : list;
});
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.set(i, status.get('id'))); ids = ids.set(i, status.get('id'));
}); });
return state; return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
}; };
function appendNormalizedAccountTimeline(state, accountId, statuses) { const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
let moreIds = Immutable.List([]); let moreIds = Immutable.List([]);
statuses.forEach((status, i) => { statuses.forEach((status, i) => {
@@ -123,133 +92,80 @@ function appendNormalizedAccountTimeline(state, accountId, statuses) {
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds)); return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
}; };
function updateTimeline(state, timeline, status) { const updateTimeline = (state, timeline, status, references) => {
state = normalizeStatus(state, status); state = normalizeStatus(state, status);
state = state.update(timeline, list => { state = state.update(timeline, Immutable.List(), list => {
if (list.includes(status.get('id'))) {
return list;
}
const reblogOfId = status.getIn(['reblog', 'id'], null); const reblogOfId = status.getIn(['reblog', 'id'], null);
if (reblogOfId !== null) { if (reblogOfId !== null) {
const otherReblogs = state.get('statuses').filter(item => item.get('reblog') === reblogOfId).map((_, itemId) => itemId); list = list.filterNot(itemId => references.includes(itemId));
list = list.filterNot(itemId => (itemId === reblogOfId || otherReblogs.includes(itemId)));
} }
return list.unshift(status.get('id')); return list.unshift(status.get('id'));
}); });
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List([]), list => (list.includes(status.get('id')) ? list : list.unshift(status.get('id'))));
return state; return state;
}; };
function deleteStatus(state, id) { const deleteStatus = (state, id, accountId, references) => {
const status = state.getIn(['statuses', id]);
if (!status) {
return state;
}
// Remove references from timelines // Remove references from timelines
['home', 'mentions'].forEach(function (timeline) { ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
state = state.update(timeline, list => list.filterNot(item => item === id)); state = state.update(timeline, list => list.filterNot(item => item === id));
}); });
// Remove references from account timelines // Remove references from account timelines
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List([]), list => list.filterNot(item => item === id)); state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
// Remove references from context
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
});
state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => {
state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
});
state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
// Remove reblogs of deleted status // Remove reblogs of deleted status
const references = state.get('statuses').filter(item => item.get('reblog') === id); references.forEach(ref => {
state = deleteStatus(state, ref[0], ref[1], []);
references.forEach(referencingId => {
state = deleteStatus(state, referencingId);
}); });
// Remove normalized status return state;
return state.deleteIn(['statuses', id]);
}; };
function normalizeAccount(state, account, relationship) { const normalizeContext = (state, id, ancestors, descendants) => {
if (relationship) { const ancestorsIds = ancestors.map(ancestor => ancestor.get('id'));
state = normalizeRelationship(state, relationship); const descendantsIds = descendants.map(descendant => descendant.get('id'));
}
return state.setIn(['accounts', account.get('id')], account);
};
function normalizeRelationship(state, relationship) {
if (state.get('suggestions').includes(relationship.get('id')) && (relationship.get('following') || relationship.get('blocking'))) {
state = state.update('suggestions', list => list.filterNot(id => id === relationship.get('id')));
}
return state.setIn(['relationships', relationship.get('id')], relationship);
};
function setSelf(state, account) {
state = normalizeAccount(state, account);
return state.set('me', account.get('id'));
};
function normalizeContext(state, status, ancestors, descendants) {
state = normalizeStatus(state, status);
let ancestorsIds = ancestors.map(ancestor => {
state = normalizeStatus(state, ancestor);
return ancestor.get('id');
}).toOrderedSet();
let descendantsIds = descendants.map(descendant => {
state = normalizeStatus(state, descendant);
return descendant.get('id');
}).toOrderedSet();
return state.withMutations(map => { return state.withMutations(map => {
map.setIn(['ancestors', status.get('id')], ancestorsIds); map.setIn(['ancestors', id], ancestorsIds);
map.setIn(['descendants', status.get('id')], descendantsIds); map.setIn(['descendants', id], descendantsIds);
}); });
}; };
function normalizeSuggestions(state, accounts) {
accounts.forEach(account => {
state = state.setIn(['accounts', account.get('id')], account);
});
return state.set('suggestions', accounts.map(account => account.get('id')));
};
export default function timelines(state = initialState, action) { export default function timelines(state = initialState, action) {
switch(action.type) { switch(action.type) {
case TIMELINE_REFRESH_SUCCESS: case TIMELINE_REFRESH_SUCCESS:
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.replace);
case TIMELINE_EXPAND_SUCCESS: case TIMELINE_EXPAND_SUCCESS:
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
case TIMELINE_UPDATE: case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status)); return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
case TIMELINE_DELETE: case TIMELINE_DELETE:
case STATUS_DELETE_SUCCESS: return deleteStatus(state, action.id, action.accountId, action.references);
return deleteStatus(state, action.id); case CONTEXT_FETCH_SUCCESS:
case REBLOG_SUCCESS: return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, Immutable.fromJS(action.response));
case ACCOUNT_SET_SELF:
return setSelf(state, Immutable.fromJS(action.account));
case ACCOUNT_FETCH_SUCCESS:
case FOLLOW_SUBMIT_SUCCESS:
return normalizeAccount(state, Immutable.fromJS(action.account), Immutable.fromJS(action.relationship));
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
return normalizeRelationship(state, Immutable.fromJS(action.relationship));
case STATUS_FETCH_SUCCESS:
return normalizeContext(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants));
case ACCOUNT_TIMELINE_FETCH_SUCCESS: case ACCOUNT_TIMELINE_FETCH_SUCCESS:
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
case ACCOUNT_TIMELINE_EXPAND_SUCCESS: case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case SUGGESTIONS_FETCH_SUCCESS:
return normalizeSuggestions(state, Immutable.fromJS(action.suggestions));
default: default:
return state; return state;
} }

View File

@@ -0,0 +1,35 @@
import {
FOLLOWERS_FETCH_SUCCESS,
FOLLOWING_FETCH_SUCCESS
} from '../actions/accounts';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import {
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS
} from '../actions/interactions';
import Immutable from 'immutable';
const initialState = Immutable.Map({
followers: Immutable.Map(),
following: Immutable.Map(),
suggestions: Immutable.List(),
reblogged_by: Immutable.Map(),
favourited_by: Immutable.Map()
});
export default function userLists(state = initialState, action) {
switch(action.type) {
case FOLLOWERS_FETCH_SUCCESS:
return state.setIn(['followers', action.id], Immutable.List(action.accounts.map(item => item.id)));
case FOLLOWING_FETCH_SUCCESS:
return state.setIn(['following', action.id], Immutable.List(action.accounts.map(item => item.id)));
case SUGGESTIONS_FETCH_SUCCESS:
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id)));
case REBLOGS_FETCH_SUCCESS:
return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
default:
return state;
}
};

View File

@@ -1,31 +1,33 @@
import { createSelector } from 'reselect' import { createSelector } from 'reselect'
import Immutable from 'immutable'; import Immutable from 'immutable';
const getStatuses = state => state.getIn(['timelines', 'statuses']); const getStatuses = state => state.get('statuses');
const getAccounts = state => state.getIn(['timelines', 'accounts']); const getAccounts = state => state.get('accounts');
const getAccountBase = (state, id) => state.getIn(['timelines', 'accounts', id], null); const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountRelationship = (state, id) => state.getIn(['timelines', 'relationships', id]); const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
export const getAccount = createSelector([getAccountBase, getAccountRelationship], (base, relationship) => { export const makeGetAccount = () => {
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
if (base === null) { if (base === null) {
return null; return null;
} }
return base.set('relationship', relationship); return base.set('relationship', relationship);
}); });
};
const getStatusBase = (state, id) => state.getIn(['timelines', 'statuses', id], null); const getStatusBase = (state, id) => state.getIn(['statuses', id], null);
export const getStatus = createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => { export const makeGetStatus = () => {
return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
if (base === null) { if (base === null) {
return null; return null;
} }
return assembleStatus(base.get('id'), statuses, accounts); return assembleStatus(base.get('id'), statuses, accounts);
}); });
};
const getAccountTimelineIds = (state, id) => state.getIn(['timelines', 'accounts_timelines', id], Immutable.List());
const assembleStatus = (id, statuses, accounts) => { const assembleStatus = (id, statuses, accounts) => {
let status = statuses.get(id, null); let status = statuses.get(id, null);
@@ -48,26 +50,6 @@ const assembleStatus = (id, statuses, accounts) => {
return status.set('reblog', reblog).set('account', accounts.get(status.get('account'))); return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
}; };
const assembleStatusList = (ids, statuses, accounts) => {
return ids.map(statusId => assembleStatus(statusId, statuses, accounts)).filterNot(status => status === null);
};
export const getAccountTimeline = createSelector([getAccountTimelineIds, getStatuses, getAccounts], assembleStatusList);
const getTimelineIds = (state, timelineType) => state.getIn(['timelines', timelineType]);
export const makeGetTimeline = () => {
return createSelector([getTimelineIds, getStatuses, getAccounts], assembleStatusList);
};
const getStatusAncestorsIds = (state, id) => state.getIn(['timelines', 'ancestors', id], Immutable.OrderedSet());
export const getStatusAncestors = createSelector([getStatusAncestorsIds, getStatuses, getAccounts], assembleStatusList);
const getStatusDescendantsIds = (state, id) => state.getIn(['timelines', 'descendants', id], Immutable.OrderedSet());
export const getStatusDescendants = createSelector([getStatusDescendantsIds, getStatuses, getAccounts], assembleStatusList);
const getNotificationsBase = state => state.get('notifications'); const getNotificationsBase = state => state.get('notifications');
export const getNotifications = createSelector([getNotificationsBase], (base) => { export const getNotifications = createSelector([getNotificationsBase], (base) => {
@@ -84,9 +66,3 @@ export const getNotifications = createSelector([getNotificationsBase], (base) =>
return arr; return arr;
}); });
const getSuggestionsBase = (state) => state.getIn(['timelines', 'suggestions']);
export const getSuggestions = createSelector([getSuggestionsBase, getAccounts], (base, accounts) => {
return base.map(accountId => accounts.get(accountId));
});

View File

@@ -6,7 +6,8 @@
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
color: #9baec8; color: #9baec8;
padding-top: 200px; padding-top: 50px;
padding-bottom: 50px;
} }
h1 { h1 {
@@ -24,10 +25,46 @@
} }
} }
p { h2 {
font: 24px/28px 'Judson', sans-serif;
font-weight: 300;
margin-bottom: 20px;
color: #fff;
}
h3 {
font: 20px/28px 'Judson', sans-serif;
font-weight: 300;
margin-bottom: 20px;
color: #d9e1e8;
}
ul, ol {
list-style: inherit;
margin-left: 20px;
&[type='a'] {
list-style-type: lower-alpha;
}
&[type='i'] {
list-style-type: lower-roman;
}
}
li > ol, li > ul {
margin-top: 20px;
}
p, li {
font: 20px/28px 'Judson', sans-serif; font: 20px/28px 'Judson', sans-serif;
font-weight: 300; font-weight: 300;
margin-bottom: 26px; margin-bottom: 26px;
a {
color: #2b90d9;
text-decoration: underline;
}
} }
em { em {
@@ -39,6 +76,32 @@
font-weight: 300; font-weight: 300;
} }
.screenshot {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
margin-bottom: 26px;
img {
max-width: 100%;
height: auto;
display: block;
}
}
.actions {
overflow: hidden;
.info {
float: right;
text-align: right;
line-height: 36px;
a {
color: #9baec8;
text-decoration: underline;
}
}
}
@media screen and (max-width: 360px) { @media screen and (max-width: 360px) {
.wrapper { .wrapper {
padding: 20px; padding: 20px;

View File

@@ -42,7 +42,8 @@
.icon-button { .icon-button {
color: #616b86; color: #616b86;
cursor: pointer; border: none;
background: transparent;
&:hover { &:hover {
color: #717b98; color: #717b98;
@@ -58,6 +59,10 @@
} }
} }
.lightbox .icon-button {
color: #282c37;
}
.compose-form__textarea, .follow-form__input { .compose-form__textarea, .follow-form__input {
background: #fff; background: #fff;
@@ -66,6 +71,17 @@
} }
} }
.emojione {
display: inline-block;
font-size: inherit;
vertical-align: middle;
margin: -.2ex .15em .2ex;
img {
width: auto;
}
}
.status__content, .reply-indicator__content { .status__content, .reply-indicator__content {
font-size: 15px; font-size: 15px;
line-height: 20px; line-height: 20px;
@@ -73,6 +89,11 @@
font-weight: 300; font-weight: 300;
overflow: hidden; overflow: hidden;
.emojione {
width: 18px;
height: 18px;
}
p { p {
margin-bottom: 20px; margin-bottom: 20px;
@@ -105,6 +126,11 @@
.status__content { .status__content {
font-size: 19px; font-size: 19px;
line-height: 24px; line-height: 24px;
.emojione {
width: 22px;
height: 22px;
}
} }
} }
@@ -117,17 +143,40 @@
} }
} }
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime { .account__header__content {
word-wrap: break-word;
font-weight: 300;
overflow: hidden;
p {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
a {
color: inherit;
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name {
text-decoration: none; text-decoration: none;
} }
.status__display-name { .status__display-name, .account__display-name {
strong { strong {
color: #fff; color: #fff;
} }
} }
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name { .status__display-name, .reply-indicator__display-name, .detailed-status__display-name, .account__display-name {
&:hover { &:hover {
strong { strong {
text-decoration: underline; text-decoration: underline;
@@ -135,6 +184,12 @@
} }
} }
.account__display-name {
strong {
display: block;
}
}
.detailed-status__display-name { .detailed-status__display-name {
color: #d9e1e8; color: #d9e1e8;
line-height: 24px; line-height: 24px;
@@ -260,3 +315,38 @@
flex-direction: column; flex-direction: column;
} }
} }
.react-autosuggest__container {
position: relative;
}
.react-autosuggest__suggestions-container {
position: absolute;
top: 100%;
width: 100%;
z-index: 99;
}
.react-autosuggest__suggestions-list {
background: #9baec8;
color: #282c37;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
font-size: 14px;
}
.react-autosuggest__suggestion {
padding: 10px;
cursor: pointer;
}
.react-autosuggest__suggestion--focused {
background: #2b90d9;
color: #fff;
}
.scrollable {
overflow-y: scroll;
overflow-x: hidden;
flex: 1 1 auto;
-webkit-overflow-scrolling: touch;
}

View File

@@ -1,3 +1,8 @@
code {
font-family: 'Roboto Mono', monospace;
font-weight: 400;
}
.form-container { .form-container {
max-width: 400px; max-width: 400px;
padding: 20px; padding: 20px;
@@ -47,9 +52,11 @@
input[type=text], input[type=email], input[type=password], textarea { input[type=text], input[type=email], input[type=password], textarea {
background: transparent; background: transparent;
box-sizing: border-box;
border: 0; border: 0;
border-bottom: 2px solid #9baec8; border-bottom: 2px solid #9baec8;
padding: 7px 0; border-radius: 2px 2px 0 0;
padding: 7px 4px;
font-size: 16px; font-size: 16px;
color: #fff; color: #fff;
display: block; display: block;
@@ -71,6 +78,7 @@
&:active, &:focus { &:active, &:focus {
border-bottom-color: #2b90d9; border-bottom-color: #2b90d9;
background: rgba(0, 0, 0, 0.1);
} }
} }
@@ -85,31 +93,9 @@
} }
} }
.prompt {
font-size: 16px;
color: #9baec8;
text-align: center;
.prompt-highlight {
font-weight: 500;
color: #fff;
}
}
code.copypasteable {
display: block;
font-family: 'Roboto Mono', monospace;
font-weight: 400;
font-size: 12px;
margin-top: 20px;
background: #282c37;
border-radius: 4px;
padding: 2px;
word-wrap: break-word;
}
.actions { .actions {
margin-top: 30px; margin-top: 30px;
}
button { button {
display: block; display: block;
@@ -148,7 +134,6 @@
} }
} }
} }
}
} }
.flash-message { .flash-message {
@@ -180,3 +165,18 @@
} }
} }
.oauth-prompt {
margin-bottom: 30px;
text-align: center;
color: #9baec8;
h2 {
font-size: 16px;
margin-bottom: 30px;
}
strong {
color: #d9e1e8;
font-weight: 500;
}
}

View File

@@ -1,4 +1,20 @@
module ApplicationCable module ApplicationCable
class Channel < ActionCable::Channel::Base class Channel < ActionCable::Channel::Base
protected
def hydrate_status(encoded_message)
message = ActiveSupport::JSON.decode(encoded_message)
return [nil, message] if message['type'] == 'delete'
status = Status.find_by(id: message['id'])
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
[status, message]
end
def filter?(status)
!status.nil? && FeedManager.instance.filter?(:public, status, current_user.account)
end
end end
end end

View File

@@ -0,0 +1,11 @@
class HashtagChannel < ApplicationCable::Channel
def subscribed
tag = params[:tag].downcase
stream_from "timeline:hashtag:#{tag}", lambda { |encoded_message|
status, message = hydrate_status(encoded_message)
next if filter?(status)
transmit message
}
end
end

View File

@@ -1,19 +1,9 @@
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
class PublicChannel < ApplicationCable::Channel class PublicChannel < ApplicationCable::Channel
def subscribed def subscribed
stream_from 'timeline:public', -> (encoded_message) do stream_from 'timeline:public', lambda { |encoded_message|
message = ActiveSupport::JSON.decode(encoded_message) status, message = hydrate_status(encoded_message)
next if filter?(status)
status = Status.find_by(id: message['id'])
next if status.nil? || current_user.account.blocking?(status.account) || (status.reblog? && current_user.account.blocking?(status.reblog.account))
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
transmit message transmit message
end }
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end end
end end

View File

@@ -2,8 +2,4 @@ class TimelineChannel < ApplicationCable::Channel
def subscribed def subscribed
stream_from "timeline:#{current_user.account_id}" stream_from "timeline:#{current_user.account_id}"
end end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end end

View File

@@ -1,5 +1,16 @@
class AboutController < ApplicationController class AboutController < ApplicationController
before_action :set_body_classes
def index def index
end
def terms
@state = 'TBD'
end
private
def set_body_classes
@body_classes = 'about-body' @body_classes = 'about-body'
end end
end end

View File

@@ -1,6 +1,9 @@
class Api::V1::AccountsController < ApiController class Api::V1::AccountsController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
before_action :require_user!, except: [:show, :following, :followers, :statuses]
before_action :set_account, except: [:verify_credentials, :suggestions] before_action :set_account, except: [:verify_credentials, :suggestions]
respond_to :json respond_to :json
def show def show
@@ -12,20 +15,48 @@ class Api::V1::AccountsController < ApiController
end end
def following def following
@following = @account.following results = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
@accounts = Account.where(id: results.map(&:target_account_id)).with_counters.to_a
next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
prev_path = following_api_v1_account_url(since_id: results.first.id) if results.size > 0
set_pagination_headers(next_path, prev_path)
render action: :index
end end
def followers def followers
@followers = @account.followers results = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
@accounts = Account.where(id: results.map(&:account_id)).with_counters.to_a
next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
prev_path = following_api_v1_account_url(since_id: results.first.id) if results.size > 0
set_pagination_headers(next_path, prev_path)
render action: :index
end
def common_followers
@accounts = @account.common_followers_with(current_user.account)
render action: :index
end end
def suggestions def suggestions
@accounts = FollowSuggestion.get(current_user.account_id) @accounts = FollowSuggestion.get(current_user.account_id)
render action: :index
end end
def statuses def statuses
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a @statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
set_maps(@statuses) set_maps(@statuses)
next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT
prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) if @statuses.size > 0
set_pagination_headers(next_path, prev_path)
end end
def follow def follow

View File

@@ -1,11 +1,19 @@
class Api::V1::FollowsController < ApiController class Api::V1::FollowsController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :follow }
before_action :require_user!
respond_to :json respond_to :json
def create def create
raise ActiveRecord::RecordNotFound if params[:uri].blank? raise ActiveRecord::RecordNotFound if params[:uri].blank?
@account = FollowService.new.call(current_user.account, params[:uri].strip).try(:target_account) @account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
render action: :show render action: :show
end end
private
def target_uri
params[:uri].strip.gsub(/\A@/, '')
end
end end

View File

@@ -1,5 +1,7 @@
class Api::V1::MediaController < ApiController class Api::V1::MediaController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :write }
before_action :require_user!
respond_to :json respond_to :json
def create def create

View File

@@ -1,16 +1,41 @@
class Api::V1::StatusesController < ApiController class Api::V1::StatusesController < ApiController
before_action :doorkeeper_authorize! before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by]
before_action :set_status, only: [:show, :context, :reblogged_by, :favourited_by]
respond_to :json respond_to :json
def show def show
@status = Status.find(params[:id])
end end
def context def context
@status = Status.find(params[:id]) @context = OpenStruct.new({ ancestors: @status.ancestors, descendants: @status.descendants })
@ancestors = @status.ancestors set_maps([@status] + @context[:ancestors] + @context[:descendants])
@descendants = @status.descendants end
set_maps([@status] + @ancestors + @descendants)
def reblogged_by
results = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
@accounts = Account.where(id: results.map(&:account_id)).with_counters.to_a
next_path = reblogged_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) if results.size > 0
set_pagination_headers(next_path, prev_path)
render action: :accounts
end
def favourited_by
results = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
@accounts = Account.where(id: results.map(&:account_id)).with_counters.to_a
next_path = favourited_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) if results.size > 0
set_pagination_headers(next_path, prev_path)
render action: :accounts
end end
def create def create
@@ -45,21 +70,9 @@ class Api::V1::StatusesController < ApiController
render action: :show render action: :show
end end
def home private
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
render action: :index
end
def mentions def set_status
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a @status = Status.find(params[:id])
set_maps(@statuses)
render action: :index
end
def public
@statuses = Status.as_public_timeline(current_user.account).paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
render action: :index
end end
end end

View File

@@ -0,0 +1,59 @@
class Api::V1::TimelinesController < ApiController
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!, only: [:home, :mentions]
respond_to :json
def home
@statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT
prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) if @statuses.size > 0
set_pagination_headers(next_path, prev_path)
render action: :index
end
def mentions
@statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT
prev_path = api_v1_mentions_timeline_url(since_id: @statuses.first.id) if @statuses.size > 0
set_pagination_headers(next_path, prev_path)
render action: :index
end
def public
@statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT
prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) if @statuses.size > 0
set_pagination_headers(next_path, prev_path)
render action: :index
end
def tag
@tag = Tag.find_by(name: params[:id].downcase)
@statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT
prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) if @statuses.size > 0
set_pagination_headers(next_path, prev_path)
render action: :index
end
end

View File

@@ -1,7 +1,13 @@
class ApiController < ApplicationController class ApiController < ApplicationController
DEFAULT_STATUSES_LIMIT = 20
DEFAULT_ACCOUNTS_LIMIT = 40
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
before_action :set_rate_limit_headers
rescue_from ActiveRecord::RecordInvalid do |e| rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: e.to_s }, status: 422 render json: { error: e.to_s }, status: 422
end end
@@ -22,14 +28,48 @@ class ApiController < ApplicationController
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503 render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
end end
def doorkeeper_unauthorized_render_options(*)
{ json: { error: 'Not authorized' } }
end
def doorkeeper_forbidden_render_options(*)
{ json: { error: 'This action is outside the authorized scopes' } }
end
protected protected
def set_rate_limit_headers
return if request.env['rack.attack.throttle_data'].nil?
now = Time.now.utc
match_data = request.env['rack.attack.throttle_data']['api']
response.headers['X-RateLimit-Limit'] = match_data[:limit].to_s
response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s
response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
end
def set_pagination_headers(next_path = nil, prev_path = nil)
links = []
links << [next_path, [['rel', 'next']]] if next_path
links << [prev_path, [['rel', 'prev']]] if prev_path
response.headers['Link'] = LinkHeader.new(links)
end
def current_resource_owner def current_resource_owner
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
end end
def current_user def current_user
super || current_resource_owner super || current_resource_owner
rescue ActiveRecord::RecordNotFound
nil
end
def require_user!
current_resource_owner
rescue ActiveRecord::RecordNotFound
render json: { error: 'This method requires an authenticated user' }, status: 422
end end
def render_empty def render_empty
@@ -37,8 +77,14 @@ class ApiController < ApplicationController
end end
def set_maps(statuses) def set_maps(statuses)
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact if current_account.nil?
@reblogs_map = Status.reblogs_map(status_ids, current_user.account) @reblogs_map = {}
@favourites_map = Status.favourites_map(status_ids, current_user.account) @favourites_map = {}
return
end
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact.uniq
@reblogs_map = Status.reblogs_map(status_ids, current_account)
@favourites_map = Status.favourites_map(status_ids, current_account)
end end
end end

View File

@@ -15,6 +15,6 @@ class HomeController < ApplicationController
end end
def find_or_create_access_token def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, nil, Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?) Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, 'read write follow', Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
end end
end end

View File

@@ -0,0 +1,12 @@
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
skip_before_action :authenticate_resource_owner!
before_action :store_current_location
before_action :authenticate_resource_owner!
private
def store_current_location
store_location_for(:user, request.url)
end
end

View File

@@ -0,0 +1,7 @@
class TagsController < ApplicationController
layout 'public'
def show
@statuses = Tag.find_by!(name: params[:id].downcase).statuses.order('id desc').with_includes.with_counters.paginate(page: params[:page], per_page: 10)
end
end

View File

@@ -47,6 +47,10 @@ module AtomBuilderHelper
xml.author(&block) xml.author(&block)
end end
def category(xml, tag)
xml.category(term: tag.name)
end
def target(xml, &block) def target(xml, &block)
xml['activity'].object(&block) xml['activity'].object(&block)
end end
@@ -186,6 +190,10 @@ module AtomBuilderHelper
stream_entry.target.media_attachments.each do |media| stream_entry.target.media_attachments.each do |media|
link_enclosure xml, media link_enclosure xml, media
end end
stream_entry.target.tags.each do |tag|
category xml, tag
end
end end
end end
end end
@@ -198,6 +206,10 @@ module AtomBuilderHelper
stream_entry.activity.media_attachments.each do |media| stream_entry.activity.media_attachments.each do |media|
link_enclosure xml, media link_enclosure xml, media
end end
stream_entry.activity.tags.each do |tag|
category xml, tag
end
end end
end end

View File

@@ -10,6 +10,6 @@ module RoutingHelper
end end
def full_asset_url(source) def full_asset_url(source)
File.join(root_url, ActionController::Base.helpers.asset_url(source)) Rails.configuration.x.use_s3 ? source : File.join(root_url, ActionController::Base.helpers.asset_url(source))
end end
end end

View File

@@ -0,0 +1,2 @@
module TagsHelper
end

View File

@@ -12,8 +12,12 @@ class FeedManager
def filter?(timeline_type, status, receiver) def filter?(timeline_type, status, receiver)
if timeline_type == :home if timeline_type == :home
filter_from_home?(status, receiver) filter_from_home?(status, receiver)
else elsif timeline_type == :mentions
filter_from_mentions?(status, receiver) filter_from_mentions?(status, receiver)
elsif timeline_type == :public
filter_from_public?(status, receiver)
else
false
end end
end end
@@ -23,8 +27,8 @@ class FeedManager
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status)) broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
end end
def broadcast(account_id, options = {}) def broadcast(timeline_id, options = {})
ActionCable.server.broadcast("timeline:#{account_id}", options) ActionCable.server.broadcast("timeline:#{timeline_id}", options)
end end
def trim(type, account_id) def trim(type, account_id)
@@ -59,13 +63,35 @@ class FeedManager
$redis $redis
end end
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
def filter_from_home?(status, receiver) def filter_from_home?(status, receiver)
replied_to_user = status.reply? ? status.thread.account : nil should_filter = false
(status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user))) || (status.reblog? && receiver.blocking?(status.reblog.account))
if status.reply? && !status.thread.account.nil? # Filter out if it's a reply
should_filter = !receiver.following?(status.thread.account) # and I'm not following the person it's a reply to
should_filter = should_filter && !(receiver.id == status.thread.account_id) # and it's not a reply to me
should_filter = should_filter && !(status.account_id == status.thread.account_id) # and it's not a self-reply
elsif status.reblog? # Filter out a reblog
should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person
end
should_filter
end end
def filter_from_mentions?(status, receiver) def filter_from_mentions?(status, receiver)
receiver.blocking?(status.account) should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself
should_filter = should_filter || receiver.blocking?(status.account) # or it's from someone I blocked
should_filter
end
def filter_from_public?(status, receiver)
should_filter = receiver.blocking?(status.account)
if status.reply? && !status.thread.account.nil?
should_filter = should_filter || receiver.blocking?(status.thread.account)
elsif status.reblog?
should_filter = should_filter || receiver.blocking?(status.reblog.account)
end
should_filter
end end
end end

View File

@@ -2,24 +2,35 @@ require 'singleton'
class Formatter class Formatter
include Singleton include Singleton
include RoutingHelper
include ActionView::Helpers::TextHelper include ActionView::Helpers::TextHelper
include ActionView::Helpers::SanitizeHelper include ActionView::Helpers::SanitizeHelper
def format(status) def format(status)
return reformat(status) unless status.local? return reformat(status.content) unless status.local?
html = status.text html = status.text
html = encode(html) html = encode(html)
html = simple_format(html, sanitize: false) html = simple_format(html, sanitize: false)
html = link_urls(html) html = link_urls(html)
html = link_mentions(html, status.mentions) html = link_mentions(html, status.mentions)
html = link_hashtags(html)
html.html_safe html.html_safe
end end
def reformat(status) def reformat(html)
sanitize(status.content, tags: %w(a br p), attributes: %w(href rel)) sanitize(html, tags: %w(a br p), attributes: %w(href rel))
end
def simplified_format(account)
return reformat(account.note) unless account.local?
html = encode(account.note)
html = link_urls(html)
html.html_safe
end end
private private
@@ -43,6 +54,17 @@ class Formatter
end end
end end
def link_hashtags(html)
html.gsub(Tag::HASHTAG_RE) do |match|
hashtag_html(match)
end
end
def hashtag_html(match)
prefix, affix = match.split('#')
"#{prefix}<a href=\"#{tag_url(affix.downcase)}\" class=\"mention hashtag\">#<span>#{affix}</span></a>"
end
def mention_html(match, account) def mention_html(match, account)
"#{match.split('@').first}<a href=\"#{TagManager.instance.url_for(account)}\" class=\"mention\">@<span>#{account.username}</span></a>" "#{match.split('@').first}<a href=\"#{TagManager.instance.url_for(account)}\" class=\"mention\">@<span>#{account.username}</span></a>"
end end

View File

@@ -1,7 +1,7 @@
class Account < ApplicationRecord class Account < ApplicationRecord
include Targetable include Targetable
MENTION_RE = /(?:^|\s|\.|>)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
# Local users # Local users
@@ -21,7 +21,7 @@ class Account < ApplicationRecord
# Local user profile validations # Local user profile validations
validates :display_name, length: { maximum: 30 }, if: 'local?' validates :display_name, length: { maximum: 30 }, if: 'local?'
validates :note, length: { maximum: 124 }, if: 'local?' validates :note, length: { maximum: 160 }, if: 'local?'
# Timelines # Timelines
has_many :stream_entries, inverse_of: :account, dependent: :destroy has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -33,12 +33,12 @@ class Account < ApplicationRecord
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
has_many :following, -> { order('follows.created_at desc') }, through: :active_relationships, source: :target_account has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
has_many :followers, -> { order('follows.created_at desc') }, through: :passive_relationships, source: :account has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
# Block relationships # Block relationships
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
has_many :blocking, -> { order('blocks.created_at desc') }, through: :block_relationships, source: :target_account has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
has_many :media_attachments, dependent: :destroy has_many :media_attachments, dependent: :destroy
@@ -112,6 +112,8 @@ class Account < ApplicationRecord
def avatar_remote_url=(url) def avatar_remote_url=(url)
self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
self[:avatar_remote_url] = url self[:avatar_remote_url] = url
rescue OpenURI::HTTPError
#
end end
def object_type def object_type
@@ -122,37 +124,48 @@ class Account < ApplicationRecord
username username
end end
def self.find_local!(username) def common_followers_with(other_account)
results = Neography::Rest.new.execute_query('MATCH (a {account_id: {a_id}})-[:follows]->(b)-[:follows]->(c {account_id: {c_id}}) RETURN b.account_id', a_id: id, c_id: other_account.id)
ids = results['data'].map(&:first)
accounts = Account.where(id: ids).with_counters.limit(20).map { |a| [a.id, a] }.to_h
ids.map { |id| accounts[id] }.compact
rescue Neography::NeographyError, Excon::Error::Socket
[]
end
class << self
def find_local!(username)
find_remote!(username, nil) find_remote!(username, nil)
end end
def self.find_remote!(username, domain) def find_remote!(username, domain)
where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take! where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take!
end end
def self.find_local(username) def find_local(username)
find_local!(username) find_local!(username)
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
nil nil
end end
def self.find_remote(username, domain) def find_remote(username, domain)
find_remote!(username, domain) find_remote!(username, domain)
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
nil nil
end end
def self.following_map(target_account_ids, account_id) def following_map(target_account_ids, account_id)
Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h
end end
def self.followed_by_map(target_account_ids, account_id) def followed_by_map(target_account_ids, account_id)
Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h
end end
def self.blocking_map(target_account_ids, account_id) def blocking_map(target_account_ids, account_id)
Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h
end end
end
before_create do before_create do
if local? if local?

View File

@@ -2,11 +2,11 @@ module Paginable
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil) scope :paginate_by_max_id, -> (limit, max_id = nil, since_id = nil) {
query = order('id desc').limit(limit) query = order(arel_table[:id].desc).limit(limit)
query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank? query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank?
query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank? query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank?
query query
end }
end end
end end

View File

@@ -1,8 +1,9 @@
class Favourite < ApplicationRecord class Favourite < ApplicationRecord
include Paginable
include Streamable include Streamable
belongs_to :account, inverse_of: :favourites belongs_to :account, inverse_of: :favourites
belongs_to :status, inverse_of: :favourites belongs_to :status, inverse_of: :favourites, touch: true
validates :status_id, uniqueness: { scope: :account_id } validates :status_id, uniqueness: { scope: :account_id }

View File

@@ -7,16 +7,18 @@ class Feed
def get(limit, max_id = nil, since_id = nil) def get(limit, max_id = nil, since_id = nil)
max_id = '+inf' if max_id.blank? max_id = '+inf' if max_id.blank?
since_id = '-inf' if since_id.blank? since_id = '-inf' if since_id.blank?
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).collect(&:last).map(&:to_i) unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
status_map = {}
# If we're after most recent items and none are there, we need to precompute the feed # If we're after most recent items and none are there, we need to precompute the feed
if unhydrated.empty? && max_id == '+inf' if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
PrecomputeFeedService.new.call(@type, @account, limit) RegenerationWorker.perform_async(@account.id, @type)
@statuses = Status.send("as_#{@type}_timeline", @account).paginate_by_max_id(limit, nil, nil)
else else
Status.where(id: unhydrated).with_includes.with_counters.each { |status| status_map[status.id] = status } status_map = Status.where(id: unhydrated).with_includes.with_counters.map { |status| [status.id, status] }.to_h
unhydrated.map { |id| status_map[id] }.compact @statuses = unhydrated.map { |id| status_map[id] }.compact
end end
@statuses
end end
private private

View File

@@ -1,4 +1,5 @@
class Follow < ApplicationRecord class Follow < ApplicationRecord
include Paginable
include Streamable include Streamable
belongs_to :account belongs_to :account

View File

@@ -1,10 +1,10 @@
class FollowSuggestion class FollowSuggestion
def self.get(for_account_id, limit = 10) class << self
def get(for_account_id, limit = 10)
neo = Neography::Rest.new neo = Neography::Rest.new
query = <<END query = <<END
START a=node:account_index(Account={id}) MATCH (a {account_id: {id}})-[:follows]->(b)-[:follows]->(c)
MATCH (a)-[:follows]->(b)-[:follows]->(c)
WHERE a <> c WHERE a <> c
AND NOT (a)-[:follows]->(c) AND NOT (a)-[:follows]->(c)
RETURN DISTINCT c.account_id, count(b), c.nodeRank RETURN DISTINCT c.account_id, count(b), c.nodeRank
@@ -32,14 +32,11 @@ END
private private
def self.fallback(for_account_id, limit) def fallback(for_account_id, limit)
neo = Neography::Rest.new neo = Neography::Rest.new
query = <<END query = <<END
START a=node:account_index(Account={id})
MATCH (b) MATCH (b)
WHERE a <> b
AND NOT (a)-[:follows]->(b)
RETURN b.account_id RETURN b.account_id
ORDER BY b.nodeRank DESC ORDER BY b.nodeRank DESC
LIMIT {limit} LIMIT {limit}
@@ -47,4 +44,5 @@ END
neo.execute_query(query, id: for_account_id, limit: limit) neo.execute_query(query, id: for_account_id, limit: limit)
end end
end
end end

View File

@@ -20,6 +20,8 @@ class MediaAttachment < ApplicationRecord
def file_remote_url=(url) def file_remote_url=(url)
self.file = URI.parse(url) self.file = URI.parse(url)
rescue OpenURI::HTTPError
#
end end
def image? def image?
@@ -34,8 +36,10 @@ class MediaAttachment < ApplicationRecord
image? ? 'image' : 'video' image? ? 'image' : 'video'
end end
private class << self
def self.file_styles(f) private
def file_styles(f)
if f.instance.image? if f.instance.image?
{ {
original: '100%', original: '100%',
@@ -43,11 +47,6 @@ private
} }
else else
{ {
original: {
convert_options: {},
format: 'webm'
},
small: { small: {
convert_options: { convert_options: {
output: { output: {
@@ -60,4 +59,5 @@ private
} }
end end
end end
end
end end

View File

@@ -5,13 +5,14 @@ class Status < ApplicationRecord
belongs_to :account, -> { with_counters }, inverse_of: :statuses belongs_to :account, -> { with_counters }, inverse_of: :statuses
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, touch: true
has_many :favourites, inverse_of: :status, dependent: :destroy has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
has_many :mentions, dependent: :destroy has_many :mentions, dependent: :destroy
has_many :media_attachments, dependent: :destroy has_many :media_attachments, dependent: :destroy
has_and_belongs_to_many :tags
validates :account, presence: true validates :account, presence: true
validates :uri, uniqueness: true, unless: 'local?' validates :uri, uniqueness: true, unless: 'local?'
@@ -21,7 +22,7 @@ class Status < ApplicationRecord
default_scope { order('id desc') } default_scope { order('id desc') }
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') } scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) } scope :with_includes, -> { includes(:account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
def local? def local?
uri.nil? uri.nil?
@@ -77,26 +78,60 @@ class Status < ApplicationRecord
ids.map { |id| statuses[id].first } ids.map { |id| statuses[id].first }
end end
def self.as_home_timeline(account) class << self
def as_home_timeline(account)
where(account: [account] + account.following).with_includes.with_counters where(account: [account] + account.following).with_includes.with_counters
end end
def self.as_mentions_timeline(account) def as_mentions_timeline(account)
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
end end
def self.as_public_timeline(account) def as_public_timeline(account = nil)
joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id').where('(reblogs.account_id IS NULL OR reblogs.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)) AND statuses.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)', account.id, account.id).with_includes.with_counters query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id').where('accounts.silenced = FALSE')
unless account.nil?
query = filter_timeline(query, account)
end end
def self.favourites_map(status_ids, account_id) query.with_includes.with_counters
end
def as_tag_timeline(tag, account = nil)
query = tag.statuses
.joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
.where('accounts.silenced = FALSE')
unless account.nil?
query = filter_timeline(query, account)
end
query.with_includes.with_counters
end
def favourites_map(status_ids, account_id)
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
end end
def self.reblogs_map(status_ids, account_id) def reblogs_map(status_ids, account_id)
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
end end
private
def filter_timeline(query, account)
blocked = Block.where(account: account).pluck(:target_account_id)
return query if blocked.empty?
query
.joins('LEFT OUTER JOIN statuses AS parents ON statuses.in_reply_to_id = parents.id')
.joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id')
.where('parents.account_id NOT IN (?)', blocked)
.where('statuses.account_id NOT IN (?)', blocked)
.where('(reblogs.id IS NULL OR reblogs.account_id NOT IN (?))', blocked)
end
end
before_validation do before_validation do
text.strip! text.strip!
end end

View File

@@ -10,7 +10,7 @@ class StreamEntry < ApplicationRecord
validates :account, :activity, presence: true validates :account, :activity, presence: true
STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) } scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) }

11
app/models/tag.rb Normal file
View File

@@ -0,0 +1,11 @@
class Tag < ApplicationRecord
has_and_belongs_to_many :statuses
HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]+)/i
validates :name, presence: true, uniqueness: true
def to_param
name
end
end

View File

@@ -7,7 +7,7 @@ class User < ApplicationRecord
validates :account, presence: true validates :account, presence: true
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') } scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
scope :recent, -> { order('created_at desc') } scope :recent, -> { order('id desc') }
scope :admins, -> { where(admin: true) } scope :admins, -> { where(admin: true) }
has_settings do |s| has_settings do |s|

View File

@@ -1,6 +1,6 @@
class BlockDomainService < BaseService class BlockDomainService < BaseService
def call(domain) def call(domain)
block = DomainBlock.find_or_create_by!(domain: domain) DomainBlock.find_or_create_by!(domain: domain)
Account.where(domain: domain).find_each do |account| Account.where(domain: domain).find_each do |account|
if account.subscribed? if account.subscribed?

View File

@@ -5,31 +5,49 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local? deliver_to_self(status) if status.account.local?
deliver_to_followers(status) deliver_to_followers(status)
deliver_to_mentioned(status) deliver_to_mentioned(status)
return if status.account.silenced?
deliver_to_hashtags(status)
deliver_to_public(status) deliver_to_public(status)
end end
private private
def deliver_to_self(status) def deliver_to_self(status)
Rails.logger.debug "Delivering status #{status.id} to author"
FeedManager.instance.push(:home, status.account, status) FeedManager.instance.push(:home, status.account, status)
end end
def deliver_to_followers(status) def deliver_to_followers(status)
status.account.followers.each do |follower| Rails.logger.debug "Delivering status #{status.id} to followers"
status.account.followers.find_each do |follower|
next if !follower.local? || FeedManager.instance.filter?(:home, status, follower) next if !follower.local? || FeedManager.instance.filter?(:home, status, follower)
FeedManager.instance.push(:home, follower, status) FeedManager.instance.push(:home, follower, status)
end end
end end
def deliver_to_mentioned(status) def deliver_to_mentioned(status)
status.mentions.each do |mention| Rails.logger.debug "Delivering status #{status.id} to mentioned accounts"
status.mentions.includes(:account).each do |mention|
mentioned_account = mention.account mentioned_account = mention.account
next if !mentioned_account.local? || mentioned_account.id == status.account_id || FeedManager.instance.filter?(:mentions, status, mentioned_account) next if !mentioned_account.local? || FeedManager.instance.filter?(:mentions, status, mentioned_account)
FeedManager.instance.push(:mentions, mentioned_account, status) FeedManager.instance.push(:mentions, mentioned_account, status)
end end
end end
def deliver_to_hashtags(status)
Rails.logger.debug "Delivering status #{status.id} to hashtags"
status.tags.find_each do |tag|
FeedManager.instance.broadcast("hashtag:#{tag.name}", type: 'update', id: status.id)
end
end
def deliver_to_public(status) def deliver_to_public(status)
FeedManager.instance.broadcast(:public, id: status.id) Rails.logger.debug "Delivering status #{status.id} to public timeline"
FeedManager.instance.broadcast(:public, type: 'update', id: status.id)
end end
end end

View File

@@ -3,6 +3,11 @@ class FetchAtomService < BaseService
response = http_client.head(url) response = http_client.head(url)
Rails.logger.debug "Remote status HEAD request returned code #{response.code}" Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
response = http_client.get(url) if response.code == 405
Rails.logger.debug "Remote status GET request returned code #{response.code}"
return nil if response.code != 200 return nil if response.code != 200
if response.mime_type == 'application/atom+xml' if response.mime_type == 'application/atom+xml'

View File

@@ -19,9 +19,11 @@ class FetchRemoteAccountService < BaseService
Rails.logger.debug "Going to webfinger #{username}@#{domain}" Rails.logger.debug "Going to webfinger #{username}@#{domain}"
return FollowRemoteAccountService.new.call("#{username}@#{domain}") return FollowRemoteAccountService.new.call("#{username}@#{domain}")
rescue TypeError => e rescue TypeError
Rails.logger.debug "Unparseable URL given: #{url}" Rails.logger.debug "Unparseable URL given: #{url}"
nil
rescue Nokogiri::XML::XPath::SyntaxError rescue Nokogiri::XML::XPath::SyntaxError
Rails.logger.debug "Invalid XML or missing namespace" Rails.logger.debug "Invalid XML or missing namespace"
nil
end end
end end

View File

@@ -9,7 +9,7 @@ class FetchRemoteStatusService < BaseService
private private
def process_atom(url, body) def process_atom(url, body)
Rails.logger.debug 'Processing Atom for remote status' Rails.logger.debug "Processing Atom for remote status at #{url}"
xml = Nokogiri::XML(body) xml = Nokogiri::XML(body)
account = extract_author(url, xml) account = extract_author(url, xml)
@@ -33,5 +33,6 @@ class FetchRemoteStatusService < BaseService
return FollowRemoteAccountService.new.call("#{username}@#{domain}") return FollowRemoteAccountService.new.call("#{username}@#{domain}")
rescue Nokogiri::XML::XPath::SyntaxError rescue Nokogiri::XML::XPath::SyntaxError
Rails.logger.debug "Invalid XML or missing namespace" Rails.logger.debug "Invalid XML or missing namespace"
nil
end end
end end

View File

@@ -15,16 +15,25 @@ class FollowRemoteAccountService < BaseService
return nil if DomainBlock.blocked?(domain) return nil if DomainBlock.blocked?(domain)
account = Account.find_remote(username, domain) account = Account.find_remote(username, domain)
return account unless account.nil? return account unless account.nil?
Rails.logger.debug "Creating new remote account for #{uri}" Rails.logger.debug "Looking up webfinger for #{uri}"
account = Account.new(username: username, domain: domain) account = Account.new(username: username, domain: domain)
data = Goldfinger.finger("acct:#{uri}") data = Goldfinger.finger("acct:#{uri}")
raise Goldfinger::Error, 'Missing resource links' if data.link('http://schemas.google.com/g/2010#updates-from').nil? || data.link('salmon').nil? || data.link('http://webfinger.net/rel/profile-page').nil? || data.link('magic-public-key').nil? raise Goldfinger::Error, 'Missing resource links' if data.link('http://schemas.google.com/g/2010#updates-from').nil? || data.link('salmon').nil? || data.link('http://webfinger.net/rel/profile-page').nil? || data.link('magic-public-key').nil?
confirmed_username, confirmed_domain = data.subject.gsub(/\Aacct:/, '').split('@')
return Account.find_local(confirmed_username) if TagManager.instance.local_domain?(confirmed_domain)
confirmed_account = Account.find_remote(confirmed_username, confirmed_domain)
return confirmed_account unless confirmed_account.nil?
Rails.logger.debug "Creating new remote account for #{uri}"
account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href
account.salmon_url = data.link('salmon').href account.salmon_url = data.link('salmon').href
account.url = data.link('http://webfinger.net/rel/profile-page').href account.url = data.link('http://webfinger.net/rel/profile-page').href

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