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:
Enabled: true
Metrics/LineLength:
Enabled: false
Style/PerlBackrefs:
AutoCorrect: false
Style/ClassAndModuleChildren:
Enabled: false
Documentation:
Metrics/BlockNesting:
Max: 2
Metrics/LineLength:
AllowURI: true
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 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.1.0'
gem 'therubyracer', platforms: :ruby
gem 'jquery-rails'
gem 'jbuilder', '~> 2.0'
gem 'sdoc', '~> 0.4.0', group: :doc
@@ -12,11 +11,13 @@ gem 'puma'
gem 'hamlit-rails'
gem 'pg'
gem 'pghero'
gem 'dotenv-rails'
gem 'font-awesome-rails'
gem 'paperclip', '~> 4.3'
gem 'paperclip-av-transcoder'
gem 'aws-sdk', '< 2.0'
gem 'http'
gem 'httplog'
@@ -37,12 +38,14 @@ gem 'htmlentities'
gem 'simple_form'
gem 'will_paginate'
gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors'
gem 'sidekiq'
gem 'ledermann-rails-settings'
gem 'neography'
gem 'react-rails'
gem 'browserify-rails'
gem 'autoprefixer-rails'
group :development, :test do
gem 'rspec-rails'
@@ -69,4 +72,5 @@ end
group :production do
gem 'rails_12factor'
gem 'lograge'
gem 'redis-rails'
end

View File

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

View File

@@ -7,11 +7,13 @@ Mastodon
[travis]: https://travis-ci.org/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:
[![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
@@ -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
**Current status of the project is early development**
## 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)
- [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
@@ -113,3 +116,10 @@ Which will re-create the updated containers, leaving databases and data as is. D
### 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.
### 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 axios from 'axios';
import Immutable from 'immutable';
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_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) {
return {
type: ACCOUNT_SET_SELF,
@@ -41,24 +52,32 @@ export function setAccountSelf(account) {
export function fetchAccount(id) {
return (dispatch, getState) => {
const boundApi = api(getState);
dispatch(fetchAccountRequest(id));
axios.all([boundApi.get(`/api/v1/accounts/${id}`), boundApi.get(`/api/v1/accounts/relationships?id=${id}`)]).then(values => {
dispatch(fetchAccountSuccess(values[0].data, values[1].data[0]));
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
dispatch(fetchAccountSuccess(response.data));
dispatch(fetchRelationships([id]));
}).catch(error => {
dispatch(fetchAccountFail(id, error));
});
};
};
export function fetchAccountTimeline(id) {
export function fetchAccountTimeline(id, replace = false) {
return (dispatch, getState) => {
dispatch(fetchAccountTimelineRequest(id));
api(getState).get(`/api/v1/accounts/${id}/statuses`).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data));
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
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 => {
dispatch(fetchAccountTimelineFail(id, error));
});
@@ -86,11 +105,10 @@ export function fetchAccountRequest(id) {
};
};
export function fetchAccountSuccess(account, relationship) {
export function fetchAccountSuccess(account) {
return {
type: ACCOUNT_FETCH_SUCCESS,
account: account,
relationship: relationship
account: account
};
};
@@ -175,11 +193,12 @@ export function fetchAccountTimelineRequest(id) {
};
};
export function fetchAccountTimelineSuccess(id, statuses) {
export function fetchAccountTimelineSuccess(id, statuses, replace) {
return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id: id,
statuses: statuses
statuses: statuses,
replace: replace
};
};
@@ -279,3 +298,108 @@ export function unblockAccountFail(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 { updateTimeline } from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
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_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
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) {
return {
type: COMPOSE_CHANGE,
@@ -32,6 +38,13 @@ export function cancelReplyCompose() {
};
};
export function mentionCompose(account) {
return {
type: COMPOSE_MENTION,
account: account
};
};
export function submitCompose() {
return function (dispatch, getState) {
dispatch(submitComposeRequest());
@@ -42,6 +55,7 @@ export function submitCompose() {
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
}).then(function (response) {
dispatch(submitComposeSuccess(response.data));
dispatch(updateTimeline('home', response.data));
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@@ -121,3 +135,27 @@ export function undoUploadCompose(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_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) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@@ -157,3 +165,71 @@ export function unfavouriteFail(status, 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 axios from 'axios';
import api from '../api';
import { deleteFromTimelines } from './timelines';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
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_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) {
return {
type: STATUS_FETCH_REQUEST,
@@ -18,12 +23,11 @@ export function fetchStatusRequest(id) {
export function fetchStatus(id) {
return (dispatch, getState) => {
const boundApi = api(getState);
dispatch(fetchStatusRequest(id));
axios.all([boundApi.get(`/api/v1/statuses/${id}`), boundApi.get(`/api/v1/statuses/${id}/context`)]).then(values => {
dispatch(fetchStatusSuccess(values[0].data, values[1].data));
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
dispatch(fetchStatusSuccess(response.data));
dispatch(fetchContext(id));
}).catch(error => {
dispatch(fetchStatusFail(id, error));
});
@@ -52,6 +56,7 @@ export function deleteStatus(id) {
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
}).catch(error => {
dispatch(deleteStatusFail(id, error));
});
@@ -79,3 +84,40 @@ export function deleteStatusFail(id, 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 {
type: SUGGESTIONS_FETCH_SUCCESS,
suggestions: suggestions
accounts: accounts
};
};

View File

@@ -1,4 +1,5 @@
import api from '../api'
import Immutable from 'immutable';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
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_FAIL = 'TIMELINE_EXPAND_FAIL';
export function refreshTimelineSuccess(timeline, statuses) {
export function refreshTimelineSuccess(timeline, statuses, replace) {
return {
type: TIMELINE_REFRESH_SUCCESS,
timeline: timeline,
statuses: statuses
statuses: statuses,
replace: replace
};
};
export function updateTimeline(timeline, status) {
return {
type: TIMELINE_UPDATE,
timeline: timeline,
status: status
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,
timeline,
status,
references
});
};
};
export function deleteFromTimelines(id) {
return {
type: TIMELINE_DELETE,
id: id
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,
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) {
dispatch(refreshTimelineRequest(timeline));
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) {
dispatch(refreshTimelineSuccess(timeline, response.data));
const ids = getState().getIn(['timelines', timeline], Immutable.List());
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) {
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) => {
const lastId = getState().getIn(['timelines', timeline]).last();
const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last();
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));
}).catch(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 () {
const style = {
display: 'inline-block',
border: 'none',
padding: '0',
background: 'transparent',
fontSize: `${this.props.size}px`,
width: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`
lineHeight: `${this.props.size}px`,
cursor: 'pointer',
...this.props.style
};
return (
<a href='#' 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>
</a>
<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}`} aria-hidden='true' />
</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: {
media: ImmutablePropTypes.list.isRequired,
height: React.PropTypes.number.isRequired
height: React.PropTypes.number.isRequired,
onOpenMedia: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleClick (url, e) {
if (e.button === 0) {
e.preventDefault();
this.props.onOpenMedia(url);
}
e.stopPropagation();
},
render () {
var children = this.props.media.take(4);
var size = children.size;
@@ -25,7 +35,7 @@ const MediaGallery = React.createClass({
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && i > 0)) {
height = 50;
}
@@ -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 (

View File

@@ -21,35 +21,28 @@ moment.updateLocale('en', {
const RelativeTimestamp = React.createClass({
getInitialState () {
return {
text: ''
};
},
propTypes: {
timestamp: React.PropTypes.string.isRequired
timestamp: React.PropTypes.string.isRequired,
now: React.PropTypes.any
},
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 () {
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 (
<span>
{this.state.text}
{string}
</span>
);
}

View File

@@ -15,13 +15,15 @@ const Status = React.createClass({
},
propTypes: {
status: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
wrapped: React.PropTypes.bool,
onReply: React.PropTypes.func,
onFavourite: React.PropTypes.func,
onReblog: 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],
@@ -42,7 +44,11 @@ const Status = React.createClass({
render () {
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') {
let displayName = status.getIn(['account', 'display_name']);
@@ -67,7 +73,7 @@ const Status = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
} 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={{ fontSize: '15px' }}>
<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>
<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,
onFavourite: React.PropTypes.func,
onReblog: React.PropTypes.func,
onDelete: React.PropTypes.func
onDelete: React.PropTypes.func,
onMention: React.PropTypes.func
},
mixins: [PureRenderMixin],
@@ -30,12 +31,18 @@ const StatusActionBar = React.createClass({
this.props.onDelete(this.props.status);
},
handleMentionClick () {
this.props.onMention(this.props.status.get('account'));
},
render () {
const { status, me } = this.props;
let menu = [];
if (status.getIn(['account', 'id']) === me) {
menu.push({ text: 'Delete', action: this.handleDeleteClick });
} else {
menu.push({ text: 'Mention', action: this.handleMentionClick });
}
return (

View File

@@ -1,5 +1,10 @@
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({
@@ -23,11 +28,14 @@ const StatusContent = React.createClass({
if (mention) {
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 {
link.setAttribute('target', '_blank');
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();
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) {
@@ -45,7 +60,7 @@ const StatusContent = React.createClass({
},
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} />;
},

View File

@@ -1,41 +1,82 @@
import Status from './status';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Status from './status';
import ImmutablePropTypes from 'react-immutable-proptypes';
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({
propTypes: {
statuses: ImmutablePropTypes.list.isRequired,
onReply: React.PropTypes.func,
onReblog: React.PropTypes.func,
onFavourite: React.PropTypes.func,
onDelete: React.PropTypes.func,
statusIds: ImmutablePropTypes.list.isRequired,
onScrollToBottom: React.PropTypes.func,
me: React.PropTypes.number
trackScroll: React.PropTypes.bool
},
getDefaultProps () {
return {
trackScroll: true
};
},
getInitialState () {
return {
now: moment()
};
},
mixins: [PureRenderMixin],
componentDidMount () {
this._interval = setInterval(() => this.setState({ now: moment() }), 60000);
},
componentWillUnmount () {
clearInterval(this._interval);
},
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
this._oldScrollPosition = scrollHeight - scrollTop;
if (scrollTop === scrollHeight - clientHeight) {
this.props.onScrollToBottom();
}
},
render () {
const { statuses, onScrollToBottom, ...other } = this.props;
componentDidUpdate (prevProps) {
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
const node = ReactDOM.findDOMNode(this);
return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto', overflowX: 'hidden' }} className='scrollable' onScroll={this.handleScroll}>
if (node.scrollTop > 0) {
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
}
}
},
render () {
const { statusIds, onScrollToBottom, trackScroll } = this.props;
const scrollableArea = (
<div className='scrollable' onScroll={this.handleScroll}>
<div>
{statuses.map((status) => {
return <Status key={status.get('id')} {...other} status={status} />;
{statusIds.map((statusId) => {
return <StatusContainer key={statusId} id={statusId} now={this.state.now} />;
})}
</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 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({
propTypes: {
media: ImmutablePropTypes.map.isRequired,
@@ -28,11 +46,23 @@ const VideoPlayer = React.createClass({
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 () {
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={{ 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>
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} />
<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={videoStyle} onClick={this.handleVideoClick} />
</div>
);
}

View File

@@ -10,11 +10,13 @@ import { setAccessToken } from '../actions/meta';
import { setAccountSelf } from '../actions/accounts';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {
applyRouterMiddleware,
Router,
Route,
hashHistory,
IndexRoute
} from 'react-router';
import { useScroll } from 'react-router-scroll';
import UI from '../features/ui';
import Account from '../features/account';
import Status from '../features/status';
@@ -24,6 +26,11 @@ import AccountTimeline from '../features/account_timeline';
import HomeTimeline from '../features/home_timeline';
import MentionsTimeline from '../features/mentions_timeline';
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();
@@ -52,9 +59,9 @@ const Mastodon = React.createClass({
return store.dispatch(deleteFromTimelines(data.id));
case 'merge':
case 'unmerge':
return store.dispatch(refreshTimeline('home'));
return store.dispatch(refreshTimeline('home', true));
case 'block':
return store.dispatch(refreshTimeline('mentions'));
return store.dispatch(refreshTimeline('mentions', true));
}
}
@@ -71,16 +78,24 @@ const Mastodon = React.createClass({
render () {
return (
<Provider store={store}>
<Router history={hashHistory}>
<Router history={hashHistory} render={applyRouterMiddleware(useScroll())}>
<Route path='/' component={UI}>
<IndexRoute component={GettingStarted} />
<Route path='/statuses/new' component={Compose} />
<Route path='/statuses/home' component={HomeTimeline} />
<Route path='/statuses/mentions' component={MentionsTimeline} />
<Route path='/statuses/all' component={PublicTimeline} />
<Route path='/statuses/tag/:id' component={HashtagTimeline} />
<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}>
<IndexRoute component={AccountTimeline} />
<Route path='/accounts/:accountId/followers' component={Followers} />
<Route path='/accounts/:accountId/following' component={Following} />
</Route>
</Route>
</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 ImmutablePropTypes from 'react-immutable-proptypes';
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({
@@ -8,7 +29,8 @@ const ActionBar = React.createClass({
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
@@ -18,6 +40,8 @@ const ActionBar = React.createClass({
let menu = [];
menu.push({ text: 'Mention', action: this.props.onMention });
if (account.get('id') === me) {
menu.push({ text: 'Edit profile', href: '/settings/profile' });
} else if (account.getIn(['relationship', 'blocking'])) {
@@ -31,26 +55,26 @@ const ActionBar = React.createClass({
}
return (
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}>
<div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}>
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
<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>
</div>
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px 5px' }}>
<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>
</div>
<div style={{ overflow: 'hidden', width: '80px', padding: '10px 5px', borderRight: '1px solid #363c4b' }}>
<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>
</div>
<div style={outerStyle}>
<div style={outerDropdownStyle}>
<DropdownMenu items={menu} icon='bars' size={24} />
</div>
<div style={{ padding: '10px', flex: '1 1 auto' }}>
<DropdownMenu items={menu} icon='bars' size={24} />
<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', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
</Link>
<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', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
</Link>
<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', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
</Link>
</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>
}
const content = { __html: account.get('note') };
return (
<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' }}>
<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' }} />
</div>
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
</a>
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span>
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')}</span>
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
</div>

View File

@@ -10,19 +10,27 @@ import {
fetchAccountTimeline,
expandAccountTimeline
} from '../../actions/accounts';
import { mentionCompose } from '../../actions/compose';
import Header from './components/header';
import {
getAccountTimeline,
getAccount
makeGetAccount
} from '../../selectors';
import LoadingIndicator from '../../components/loading_indicator';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
const mapStateToProps = (state, props) => ({
account: getAccount(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me'])
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, Number(props.params.accountId)),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const Account = React.createClass({
@@ -39,7 +47,7 @@ const Account = React.createClass({
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
},
componentWillReceiveProps(nextProps) {
componentWillReceiveProps (nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && 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 () {
const { account, me } = this.props;
@@ -74,9 +86,10 @@ const Account = React.createClass({
return (
<Column>
<ColumnBackButton />
<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}
</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 PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { getAccountTimeline } from '../../selectors';
import {
fetchAccountTimeline,
expandAccountTimeline
} 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 LoadingIndicator from '../../components/loading_indicator';
const mapStateToProps = (state, props) => ({
statuses: getAccountTimeline(state, Number(props.params.accountId)),
me: state.getIn(['timelines', 'me'])
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
me: state.getIn(['meta', 'me'])
});
const AccountTimeline = React.createClass({
@@ -26,7 +18,7 @@ const AccountTimeline = React.createClass({
propTypes: {
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
statuses: ImmutablePropTypes.list
statusIds: ImmutablePropTypes.list
},
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 () {
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
},
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 ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { Link } from 'react-router';
import AccountContainer from '../../followers/containers/account_container';
const outerStyle = {
marginBottom: '10px',
borderTop: '1px solid #616b86',
position: 'relative'
};
@@ -17,32 +13,12 @@ const headerStyle = {
padding: '10px',
color: '#9baec8',
background: '#454b5e',
width: '120px',
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'
overflow: 'hidden'
};
const nextStyle = {
display: 'inline-block',
float: 'right',
fontWeight: '400',
color: '#2b90d9'
};
@@ -50,7 +26,7 @@ const nextStyle = {
const SuggestionsBox = React.createClass({
propTypes: {
accounts: ImmutablePropTypes.list.isRequired,
accountIds: ImmutablePropTypes.list,
perWindow: React.PropTypes.number
},
@@ -73,7 +49,7 @@ const SuggestionsBox = React.createClass({
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;
}
@@ -81,16 +57,16 @@ const SuggestionsBox = React.createClass({
},
render () {
const { accounts, perWindow } = this.props;
const { accountIds, perWindow } = this.props;
if (accounts.size === 0) {
if (!accountIds || accountIds.size === 0) {
return <div />;
}
let nextLink = '';
if (accounts.size > perWindow) {
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Next</a>;
if (accountIds.size > perWindow) {
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Refresh</a>;
}
return (
@@ -99,21 +75,7 @@ const SuggestionsBox = React.createClass({
Who to follow {nextLink}
</strong>
{accounts.skip(perWindow * this.state.index).take(perWindow).map(account => {
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>
)
})}
{accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)}
</div>
);
}

View File

@@ -1,9 +1,8 @@
import { connect } from 'react-redux';
import { getSuggestions } from '../../../selectors';
import SuggestionsBox from '../components/suggestions_box';
const mapStateToProps = (state) => ({
accounts: getSuggestions(state)
accountIds: state.getIn(['user_lists', 'suggestions'])
});
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>
<div className='static-content'>
<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>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>

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 () {
return (
<Column icon='home' heading='Home'>
<StatusListContainer type='home' />
<StatusListContainer {...this.props} type='home' />
</Column>
);
},

View File

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

View File

@@ -1,11 +1,12 @@
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
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 Column from '../ui/components/column';
import {
refreshTimeline,
updateTimeline
} from '../../actions/timelines';
updateTimeline,
deleteFromTimelines
} from '../../actions/timelines';
const PublicTimeline = React.createClass({
@@ -24,7 +25,12 @@ const PublicTimeline = React.createClass({
this.subscription = App.cable.subscriptions.create('PublicChannel', {
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,
onFavourite: React.PropTypes.func.isRequired,
onDelete: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired,
me: React.PropTypes.number.isRequired
},
@@ -23,6 +24,8 @@ const ActionBar = React.createClass({
if (me === status.getIn(['account', 'id'])) {
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
} else {
menu.push({ text: 'Mention', action: () => this.props.onMention(status.get('account')) });
}
return (

View File

@@ -6,6 +6,7 @@ import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import VideoPlayer from '../../../components/video_player';
import moment from 'moment';
import { Link } from 'react-router';
const DetailedStatus = React.createClass({
@@ -14,7 +15,8 @@ const DetailedStatus = React.createClass({
},
propTypes: {
status: ImmutablePropTypes.map.isRequired
status: ImmutablePropTypes.map.isRequired,
onOpenMedia: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
@@ -36,7 +38,7 @@ const DetailedStatus = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
} 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}
<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>
);

View File

@@ -9,20 +9,33 @@ import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar';
import Column from '../ui/components/column';
import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose';
import {
replyCompose,
mentionCompose
} from '../../actions/compose';
import { deleteStatus } from '../../actions/statuses';
import {
getStatus,
makeGetStatus,
getStatusAncestors,
getStatusDescendants
} 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) => ({
status: getStatus(state, Number(props.params.statusId)),
ancestors: getStatusAncestors(state, Number(props.params.statusId)),
descendants: getStatusDescendants(state, Number(props.params.statusId)),
me: state.getIn(['timelines', 'me'])
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({
status: getStatus(state, Number(props.params.statusId)),
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const Status = React.createClass({
@@ -30,8 +43,8 @@ const Status = React.createClass({
params: React.PropTypes.object.isRequired,
dispatch: React.PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
ancestors: ImmutablePropTypes.orderedSet.isRequired,
descendants: ImmutablePropTypes.orderedSet.isRequired
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list
},
mixins: [PureRenderMixin],
@@ -62,12 +75,21 @@ const Status = React.createClass({
this.props.dispatch(deleteStatus(status.get('id')));
},
handleMentionClick (account) {
this.props.dispatch(mentionCompose(account));
},
handleOpenMedia (url) {
this.props.dispatch(openMedia(url));
},
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 () {
const { status, ancestors, descendants, me } = this.props;
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, me } = this.props;
if (status === null) {
return (
@@ -79,20 +101,32 @@ const Status = React.createClass({
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 (
<Column>
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
<div>{this.renderChildren(ancestors)}</div>
<ColumnBackButton />
<DetailedStatus status={status} me={me} />
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />
<ScrollContainer scrollKey='thread'>
<div className='scrollable'>
{ancestors}
<div>{this.renderChildren(descendants)}</div>
</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>
</ScrollContainer>
</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 ReplyIndicator from './reply_indicator';
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({
propTypes: {
text: React.PropTypes.string.isRequired,
suggestions: React.PropTypes.array,
is_submitting: React.PropTypes.bool,
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
@@ -35,7 +86,39 @@ const ComposeForm = React.createClass({
componentDidUpdate (prevProps) {
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} />;
}
const inputProps = {
placeholder: 'What is on your mind?',
value: this.props.text,
onKeyUp: this.handleKeyUp,
onChange: this.handleChange,
disabled: disabled
};
return (
<div style={{ padding: '10px' }}>
{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={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>

View File

@@ -24,7 +24,7 @@ const UploadButton = React.createClass({
return (
<div>
<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>
<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 ComposeForm from '../components/compose_form';
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
import { getStatus } from '../../../selectors';
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import {
changeCompose,
submitCompose,
cancelReplyCompose,
clearComposeSuggestions,
fetchComposeSuggestions
} from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
};
};
return mapStateToProps;
};
const mapDispatchToProps = function (dispatch) {
return {
onChange: function (text) {
onChange (text) {
dispatch(changeCompose(text));
},
onSubmit: function () {
onSubmit () {
dispatch(submitCompose());
},
onCancelReply: function () {
onCancelReply () {
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';
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);

View File

@@ -1,57 +1,18 @@
import { connect } from 'react-redux';
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 { makeGetTimeline } from '../../../selectors';
import { deleteStatus } from '../../../actions/statuses';
import { connect } from 'react-redux';
import StatusList from '../../../components/status_list';
import { expandTimeline } from '../../../actions/timelines';
import Immutable from 'immutable';
const makeMapStateToProps = () => {
const getTimeline = makeGetTimeline();
const mapStateToProps = (state, props) => ({
statuses: getTimeline(state, props.type),
me: state.getIn(['timelines', 'me'])
});
return mapStateToProps;
};
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', props.type], Immutable.List())
});
const mapDispatchToProps = function (dispatch, props) {
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 () {
dispatch(expandTimeline(props.type));
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
dispatch(expandTimeline(props.type, props.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 MediaQuery from 'react-responsive';
import TabsBar from './components/tabs_bar';
import ModalContainer from './containers/modal_container';
const UI = React.createClass({
@@ -28,14 +29,15 @@ const UI = React.createClass({
<MediaQuery minWidth={layoutBreakpoint}>
<ColumnsArea>
<Compose />
<HomeTimeline />
<MentionsTimeline />
<HomeTimeline trackScroll={false} />
<MentionsTimeline trackScroll={false} />
{this.props.children}
</ColumnsArea>
</MediaQuery>
<NotificationsContainer />
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
<ModalContainer />
</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_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
COMPOSE_SUBMIT_FAIL,
@@ -9,11 +10,13 @@ import {
COMPOSE_UPLOAD_SUCCESS,
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
COMPOSE_UPLOAD_PROGRESS,
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import Immutable from 'immutable';
import Immutable from 'immutable';
const initialState = Immutable.Map({
text: '',
@@ -21,7 +24,8 @@ const initialState = Immutable.Map({
is_submitting: false,
is_uploading: false,
progress: 0,
media_attachments: Immutable.List([]),
media_attachments: Immutable.List(),
suggestions: [],
me: null
});
@@ -32,7 +36,7 @@ function statusToTextMentions(state, status) {
if (status.getIn(['account', 'id']) !== me) {
set = set.add(`@${status.getIn(['account', 'acct'])} `);
}
return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
};
@@ -92,6 +96,12 @@ export default function compose(state = initialState, action) {
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:
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:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);

View File

@@ -3,7 +3,7 @@ import {
FOLLOW_SUBMIT_REQUEST,
FOLLOW_SUBMIT_SUCCESS,
FOLLOW_SUBMIT_FAIL
} from '../actions/follow';
} from '../actions/follow';
import Immutable from 'immutable';
const initialState = Immutable.Map({

View File

@@ -5,6 +5,11 @@ import compose from './compose';
import follow from './follow';
import notifications from './notifications';
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({
timelines,
@@ -13,4 +18,9 @@ export default combineReducers({
follow,
notifications,
loadingBar: loadingBarReducer,
modal,
user_lists,
accounts,
statuses,
relationships
});

View File

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

@@ -2,8 +2,8 @@ import {
NOTIFICATION_SHOW,
NOTIFICATION_DISMISS,
NOTIFICATION_CLEAR
} from '../actions/notifications';
import Immutable from 'immutable';
} from '../actions/notifications';
import Immutable from 'immutable';
const initialState = Immutable.List([]);

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

@@ -3,116 +3,85 @@ import {
TIMELINE_UPDATE,
TIMELINE_DELETE,
TIMELINE_EXPAND_SUCCESS
} from '../actions/timelines';
} from '../actions/timelines';
import {
REBLOG_SUCCESS,
UNREBLOG_SUCCESS,
FAVOURITE_SUCCESS,
UNFAVOURITE_SUCCESS
} from '../actions/interactions';
} from '../actions/interactions';
import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS,
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts';
} from '../actions/accounts';
import {
STATUS_FETCH_SUCCESS,
STATUS_DELETE_SUCCESS
} from '../actions/statuses';
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import Immutable from 'immutable';
CONTEXT_FETCH_SUCCESS
} from '../actions/statuses';
import Immutable from 'immutable';
const initialState = Immutable.Map({
home: Immutable.List([]),
mentions: Immutable.List([]),
public: Immutable.List([]),
statuses: Immutable.Map(),
accounts: Immutable.Map(),
home: Immutable.List(),
mentions: Immutable.List(),
public: Immutable.List(),
tag: Immutable.List(),
accounts_timelines: Immutable.Map(),
me: null,
ancestors: Immutable.Map(),
descendants: Immutable.Map(),
relationships: Immutable.Map(),
suggestions: Immutable.List([])
descendants: Immutable.Map()
});
function normalizeStatus(state, status) {
// Separate account
let account = status.get('account');
status = status.set('account', account.get('id'));
const normalizeStatus = (state, status) => {
const replyToId = status.get('in_reply_to_id');
const id = status.get('id');
// Separate reblog, repeat for reblog
let reblog = status.get('reblog', null);
if (reblog !== null) {
status = status.set('reblog', reblog.get('id'));
state = normalizeStatus(state, reblog);
}
// Replies
if (status.get('in_reply_to_id')) {
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 => {
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')));
if (replyToId) {
if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
}
map.setIn(['accounts', account.get('id')], account);
map.setIn(['statuses', status.get('id')], status);
});
if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
}
}
return state;
};
function normalizeTimeline(state, timeline, statuses) {
let ids = Immutable.List([]);
const normalizeTimeline = (state, timeline, statuses, replace = false) => {
let ids = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
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) {
let moreIds = Immutable.List([]);
const appendNormalizedTimeline = (state, timeline, statuses) => {
let moreIds = Immutable.List();
statuses.forEach((status, i) => {
state = normalizeStatus(state, status);
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) {
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => {
return (list.size > 0) ? list.clear() : list;
});
const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
let ids = Immutable.List();
statuses.forEach((status, i) => {
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([]);
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));
};
function updateTimeline(state, timeline, status) {
const updateTimeline = (state, timeline, status, references) => {
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);
if (reblogOfId !== null) {
const otherReblogs = state.get('statuses').filter(item => item.get('reblog') === reblogOfId).map((_, itemId) => itemId);
list = list.filterNot(itemId => (itemId === reblogOfId || otherReblogs.includes(itemId)));
list = list.filterNot(itemId => references.includes(itemId));
}
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;
};
function deleteStatus(state, id) {
const status = state.getIn(['statuses', id]);
if (!status) {
return state;
}
const deleteStatus = (state, id, accountId, references) => {
// 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));
});
// 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
const references = state.get('statuses').filter(item => item.get('reblog') === id);
references.forEach(referencingId => {
state = deleteStatus(state, referencingId);
references.forEach(ref => {
state = deleteStatus(state, ref[0], ref[1], []);
});
// Remove normalized status
return state.deleteIn(['statuses', id]);
return state;
};
function normalizeAccount(state, account, relationship) {
if (relationship) {
state = normalizeRelationship(state, relationship);
}
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();
const normalizeContext = (state, id, ancestors, descendants) => {
const ancestorsIds = ancestors.map(ancestor => ancestor.get('id'));
const descendantsIds = descendants.map(descendant => descendant.get('id'));
return state.withMutations(map => {
map.setIn(['ancestors', status.get('id')], ancestorsIds);
map.setIn(['descendants', status.get('id')], descendantsIds);
map.setIn(['ancestors', id], ancestorsIds);
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) {
switch(action.type) {
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:
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
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 STATUS_DELETE_SUCCESS:
return deleteStatus(state, action.id);
case REBLOG_SUCCESS:
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));
return deleteStatus(state, action.id, action.accountId, action.references);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
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:
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
case SUGGESTIONS_FETCH_SUCCESS:
return normalizeSuggestions(state, Immutable.fromJS(action.suggestions));
default:
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 Immutable from 'immutable';
const getStatuses = state => state.getIn(['timelines', 'statuses']);
const getAccounts = state => state.getIn(['timelines', 'accounts']);
const getStatuses = state => state.get('statuses');
const getAccounts = state => state.get('accounts');
const getAccountBase = (state, id) => state.getIn(['timelines', 'accounts', id], null);
const getAccountRelationship = (state, id) => state.getIn(['timelines', 'relationships', id]);
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
export const getAccount = createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
if (base === null) {
return null;
}
export const makeGetAccount = () => {
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
if (base === 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) => {
if (base === null) {
return null;
}
export const makeGetStatus = () => {
return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
if (base === null) {
return null;
}
return assembleStatus(base.get('id'), statuses, accounts);
});
const getAccountTimelineIds = (state, id) => state.getIn(['timelines', 'accounts_timelines', id], Immutable.List());
return assembleStatus(base.get('id'), statuses, accounts);
});
};
const assembleStatus = (id, statuses, accounts) => {
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')));
};
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');
export const getNotifications = createSelector([getNotificationsBase], (base) => {
@@ -84,9 +66,3 @@ export const getNotifications = createSelector([getNotificationsBase], (base) =>
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;
margin: 0 auto;
color: #9baec8;
padding-top: 200px;
padding-top: 50px;
padding-bottom: 50px;
}
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-weight: 300;
margin-bottom: 26px;
a {
color: #2b90d9;
text-decoration: underline;
}
}
em {
@@ -39,6 +76,32 @@
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) {
.wrapper {
padding: 20px;

View File

@@ -42,7 +42,8 @@
.icon-button {
color: #616b86;
cursor: pointer;
border: none;
background: transparent;
&:hover {
color: #717b98;
@@ -58,6 +59,10 @@
}
}
.lightbox .icon-button {
color: #282c37;
}
.compose-form__textarea, .follow-form__input {
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 {
font-size: 15px;
line-height: 20px;
@@ -73,6 +89,11 @@
font-weight: 300;
overflow: hidden;
.emojione {
width: 18px;
height: 18px;
}
p {
margin-bottom: 20px;
@@ -105,6 +126,11 @@
.status__content {
font-size: 19px;
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;
}
.status__display-name {
.status__display-name, .account__display-name {
strong {
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 {
strong {
text-decoration: underline;
@@ -135,6 +184,12 @@
}
}
.account__display-name {
strong {
display: block;
}
}
.detailed-status__display-name {
color: #d9e1e8;
line-height: 24px;
@@ -260,3 +315,38 @@
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 {
max-width: 400px;
padding: 20px;
@@ -47,9 +52,11 @@
input[type=text], input[type=email], input[type=password], textarea {
background: transparent;
box-sizing: border-box;
border: 0;
border-bottom: 2px solid #9baec8;
padding: 7px 0;
border-radius: 2px 2px 0 0;
padding: 7px 4px;
font-size: 16px;
color: #fff;
display: block;
@@ -71,6 +78,7 @@
&:active, &:focus {
border-bottom-color: #2b90d9;
background: rgba(0, 0, 0, 0.1);
}
}
@@ -85,67 +93,44 @@
}
}
.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 {
margin-top: 30px;
}
button {
display: block;
width: 100%;
border: 0;
border-radius: 4px;
background: #2b90d9;
color: #fff;
font-size: 18px;
padding: 10px;
text-transform: uppercase;
cursor: pointer;
font-weight: 500;
outline: 0;
margin-bottom: 10px;
button {
display: block;
width: 100%;
border: 0;
border-radius: 4px;
background: #2b90d9;
color: #fff;
font-size: 18px;
padding: 10px;
text-transform: uppercase;
cursor: pointer;
font-weight: 500;
outline: 0;
margin-bottom: 10px;
&:hover {
background-color: lighten(#2b90d9, 5%);
}
&:active, &:focus {
position: relative;
top: 1px;
background-color: darken(#2b90d9, 5%);
}
&.negative {
background: #df405a;
&:hover {
background-color: lighten(#2b90d9, 5%);
background-color: lighten(#df405a, 5%);
}
&:active, &:focus {
position: relative;
top: 1px;
background-color: darken(#2b90d9, 5%);
}
&.negative {
background: #df405a;
&:hover {
background-color: lighten(#df405a, 5%);
}
&:active, &:focus {
background-color: darken(#df405a, 5%);
}
background-color: darken(#df405a, 5%);
}
}
}
@@ -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
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

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
def subscribed
stream_from 'timeline:public', -> (encoded_message) do
message = ActiveSupport::JSON.decode(encoded_message)
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)
stream_from 'timeline:public', lambda { |encoded_message|
status, message = hydrate_status(encoded_message)
next if filter?(status)
transmit message
end
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
}
end
end

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
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]
respond_to :json
respond_to :json
def show
end
@@ -12,20 +15,48 @@ class Api::V1::AccountsController < ApiController
end
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
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
def suggestions
@accounts = FollowSuggestion.get(current_user.account_id)
render action: :index
end
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)
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
def follow

View File

@@ -1,11 +1,19 @@
class Api::V1::FollowsController < ApiController
before_action :doorkeeper_authorize!
respond_to :json
before_action -> { doorkeeper_authorize! :follow }
before_action :require_user!
respond_to :json
def create
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
end
private
def target_uri
params[:uri].strip.gsub(/\A@/, '')
end
end

View File

@@ -1,6 +1,8 @@
class Api::V1::MediaController < ApiController
before_action :doorkeeper_authorize!
respond_to :json
before_action -> { doorkeeper_authorize! :write }
before_action :require_user!
respond_to :json
def create
@media = MediaAttachment.create!(account: current_user.account, file: params[:file])

View File

@@ -1,16 +1,41 @@
class Api::V1::StatusesController < ApiController
before_action :doorkeeper_authorize!
respond_to :json
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
def show
@status = Status.find(params[:id])
end
def context
@status = Status.find(params[:id])
@ancestors = @status.ancestors
@descendants = @status.descendants
set_maps([@status] + @ancestors + @descendants)
@context = OpenStruct.new({ ancestors: @status.ancestors, descendants: @status.descendants })
set_maps([@status] + @context[:ancestors] + @context[:descendants])
end
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
def create
@@ -45,21 +70,9 @@ class Api::V1::StatusesController < ApiController
render action: :show
end
def home
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
set_maps(@statuses)
render action: :index
end
private
def mentions
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
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
def set_status
@status = Status.find(params[:id])
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
DEFAULT_STATUSES_LIMIT = 20
DEFAULT_ACCOUNTS_LIMIT = 40
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token
before_action :set_rate_limit_headers
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: e.to_s }, status: 422
end
@@ -22,14 +28,48 @@ class ApiController < ApplicationController
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
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
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
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
end
def current_user
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
def render_empty
@@ -37,8 +77,14 @@ class ApiController < ApplicationController
end
def set_maps(statuses)
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact
@reblogs_map = Status.reblogs_map(status_ids, current_user.account)
@favourites_map = Status.favourites_map(status_ids, current_user.account)
if current_account.nil?
@reblogs_map = {}
@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

View File

@@ -15,6 +15,6 @@ class HomeController < ApplicationController
end
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

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

View File

@@ -10,6 +10,6 @@ module RoutingHelper
end
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

View File

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

View File

@@ -12,8 +12,12 @@ class FeedManager
def filter?(timeline_type, status, receiver)
if timeline_type == :home
filter_from_home?(status, receiver)
else
elsif timeline_type == :mentions
filter_from_mentions?(status, receiver)
elsif timeline_type == :public
filter_from_public?(status, receiver)
else
false
end
end
@@ -23,8 +27,8 @@ class FeedManager
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
end
def broadcast(account_id, options = {})
ActionCable.server.broadcast("timeline:#{account_id}", options)
def broadcast(timeline_id, options = {})
ActionCable.server.broadcast("timeline:#{timeline_id}", options)
end
def trim(type, account_id)
@@ -59,13 +63,35 @@ class FeedManager
$redis
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)
replied_to_user = status.reply? ? status.thread.account : nil
(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))
should_filter = false
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
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

View File

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

View File

@@ -1,7 +1,7 @@
class Account < ApplicationRecord
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
# Local users
@@ -21,7 +21,7 @@ class Account < ApplicationRecord
# Local user profile validations
validates :display_name, length: { maximum: 30 }, if: 'local?'
validates :note, length: { maximum: 124 }, if: 'local?'
validates :note, length: { maximum: 160 }, if: 'local?'
# Timelines
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 :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 :followers, -> { order('follows.created_at desc') }, through: :passive_relationships, source: :account
has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
# Block relationships
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
@@ -112,6 +112,8 @@ class Account < ApplicationRecord
def avatar_remote_url=(url)
self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
self[:avatar_remote_url] = url
rescue OpenURI::HTTPError
#
end
def object_type
@@ -122,36 +124,47 @@ class Account < ApplicationRecord
username
end
def self.find_local!(username)
find_remote!(username, nil)
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
def self.find_remote!(username, domain)
where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take!
end
class << self
def find_local!(username)
find_remote!(username, nil)
end
def self.find_local(username)
find_local!(username)
rescue ActiveRecord::RecordNotFound
nil
end
def find_remote!(username, domain)
where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take!
end
def self.find_remote(username, domain)
find_remote!(username, domain)
rescue ActiveRecord::RecordNotFound
nil
end
def find_local(username)
find_local!(username)
rescue ActiveRecord::RecordNotFound
nil
end
def self.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
end
def find_remote(username, domain)
find_remote!(username, domain)
rescue ActiveRecord::RecordNotFound
nil
end
def self.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
end
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
end
def self.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
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
end
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
end
end
before_create do

View File

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

View File

@@ -1,8 +1,9 @@
class Favourite < ApplicationRecord
include Paginable
include Streamable
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 }

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
class FollowSuggestion
def self.get(for_account_id, limit = 10)
neo = Neography::Rest.new
class << self
def get(for_account_id, limit = 10)
neo = Neography::Rest.new
query = <<END
START a=node:account_index(Account={id})
MATCH (a)-[:follows]->(b)-[:follows]->(c)
query = <<END
MATCH (a {account_id: {id}})-[:follows]->(b)-[:follows]->(c)
WHERE a <> c
AND NOT (a)-[:follows]->(c)
RETURN DISTINCT c.account_id, count(b), c.nodeRank
@@ -12,39 +12,37 @@ ORDER BY count(b) DESC, c.nodeRank DESC
LIMIT {limit}
END
results = neo.execute_query(query, id: for_account_id, limit: limit)
results = neo.execute_query(query, id: for_account_id, limit: limit)
if results.empty? || results['data'].empty?
results = fallback(for_account_id, limit)
elsif results['data'].size < limit
results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq
if results.empty? || results['data'].empty?
results = fallback(for_account_id, limit)
elsif results['data'].size < limit
results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq
end
account_ids = results['data'].map(&:first)
blocked_ids = Block.where(account_id: for_account_id).pluck(:target_account_id)
accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h
account_ids.map { |id| accounts_map[id] }.compact
rescue Neography::NeographyError, Excon::Error::Socket => e
Rails.logger.error e
return []
end
account_ids = results['data'].map(&:first)
blocked_ids = Block.where(account_id: for_account_id).pluck(:target_account_id)
accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h
private
account_ids.map { |id| accounts_map[id] }.compact
rescue Neography::NeographyError, Excon::Error::Socket => e
Rails.logger.error e
return []
end
def fallback(for_account_id, limit)
neo = Neography::Rest.new
private
def self.fallback(for_account_id, limit)
neo = Neography::Rest.new
query = <<END
START a=node:account_index(Account={id})
query = <<END
MATCH (b)
WHERE a <> b
AND NOT (a)-[:follows]->(b)
RETURN b.account_id
ORDER BY b.nodeRank DESC
LIMIT {limit}
END
neo.execute_query(query, id: for_account_id, limit: limit)
neo.execute_query(query, id: for_account_id, limit: limit)
end
end
end

View File

@@ -20,6 +20,8 @@ class MediaAttachment < ApplicationRecord
def file_remote_url=(url)
self.file = URI.parse(url)
rescue OpenURI::HTTPError
#
end
def image?
@@ -34,30 +36,28 @@ class MediaAttachment < ApplicationRecord
image? ? 'image' : 'video'
end
private
def self.file_styles(f)
if f.instance.image?
{
original: '100%',
small: '510x680>'
}
else
{
original: {
convert_options: {},
format: 'webm'
},
class << self
private
small: {
convert_options: {
output: {
vf: 'scale=\'min(510\, iw):min(680\, ih)\':force_original_aspect_ratio=decrease'
}
},
format: 'png',
time: 1
def file_styles(f)
if f.instance.image?
{
original: '100%',
small: '510x680>'
}
}
else
{
small: {
convert_options: {
output: {
vf: 'scale=\'min(510\, iw):min(680\, ih)\':force_original_aspect_ratio=decrease'
}
},
format: 'png',
time: 1
}
}
end
end
end
end

View File

@@ -5,13 +5,14 @@ class Status < ApplicationRecord
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 :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 :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 :mentions, dependent: :destroy
has_many :media_attachments, dependent: :destroy
has_and_belongs_to_many :tags
validates :account, presence: true
validates :uri, uniqueness: true, unless: 'local?'
@@ -21,7 +22,7 @@ class Status < ApplicationRecord
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_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?
uri.nil?
@@ -77,24 +78,58 @@ class Status < ApplicationRecord
ids.map { |id| statuses[id].first }
end
def self.as_home_timeline(account)
where(account: [account] + account.following).with_includes.with_counters
end
class << self
def as_home_timeline(account)
where(account: [account] + account.following).with_includes.with_counters
end
def self.as_mentions_timeline(account)
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
end
def as_mentions_timeline(account)
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
end
def self.as_public_timeline(account)
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
end
def as_public_timeline(account = nil)
query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id').where('accounts.silenced = FALSE')
def self.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
end
unless account.nil?
query = filter_timeline(query, account)
end
def self.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
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
end
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
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

View File

@@ -10,7 +10,7 @@ class StreamEntry < ApplicationRecord
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]) }

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
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) }
has_settings do |s|

View File

@@ -1,6 +1,6 @@
class BlockDomainService < BaseService
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|
if account.subscribed?

View File

@@ -5,31 +5,49 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local?
deliver_to_followers(status)
deliver_to_mentioned(status)
return if status.account.silenced?
deliver_to_hashtags(status)
deliver_to_public(status)
end
private
def deliver_to_self(status)
Rails.logger.debug "Delivering status #{status.id} to author"
FeedManager.instance.push(:home, status.account, status)
end
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)
FeedManager.instance.push(:home, follower, status)
end
end
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
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)
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)
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

View File

@@ -3,6 +3,11 @@ class FetchAtomService < BaseService
response = http_client.head(url)
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
if response.mime_type == 'application/atom+xml'

View File

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

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