Compare commits
104 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
bf5f55a6bb | ||
|
aabf884c5f | ||
|
17903c6dae | ||
|
c5e03a2e0d | ||
|
448ab18a20 | ||
|
b13e7dda1f | ||
|
8d7fc5da6c | ||
|
9aecc0f48a | ||
|
86574ea524 | ||
|
b6832553ff | ||
|
cff0b03cbb | ||
|
2f21f4cc01 | ||
|
2d69bf4b3a | ||
|
bb4d1eb2e8 | ||
|
096bfbad96 | ||
|
11cbe49ffc | ||
|
dbe00a4156 | ||
|
0400734df7 | ||
|
a39a92bd22 | ||
|
bec47e40f5 | ||
|
d0d799f911 | ||
|
f5c6baf29d | ||
|
c3559d18a3 | ||
|
4a6cc46e81 | ||
|
23fc424b7a | ||
|
b835f4aa1c | ||
|
ea4170785a | ||
|
d14967e1c8 | ||
|
45230c56ab | ||
|
3ab193bc3f | ||
|
93212bc2c4 | ||
|
7bb28bf780 | ||
|
262adb9791 | ||
|
eb47409036 | ||
|
a698b767c1 | ||
|
48b9619439 | ||
|
62292797ec | ||
|
6471a548fe | ||
|
4ea13d8dc9 | ||
|
2463c72006 | ||
|
087b993892 | ||
|
6d5ef89356 | ||
|
98c3a5e9c3 | ||
|
6d26bfd147 | ||
|
0405ef977a | ||
|
159203a7bc | ||
|
1828df9bc0 | ||
|
abf664b560 | ||
|
829d189f44 | ||
|
3731230c6d | ||
|
e4671adc25 | ||
|
c003e70758 | ||
|
562044f36a | ||
|
0160d1d9b5 | ||
|
aff22bfdb5 | ||
|
4b357ecf98 | ||
|
d427df4a8a | ||
|
76a717d549 | ||
|
87aabeb7af | ||
|
77045d0886 | ||
|
bc1dba22c5 | ||
|
e7723ab662 | ||
|
e7cd6da938 | ||
|
5ff2762fcf | ||
|
53fb7b1aa8 | ||
|
a59d10d3df | ||
|
9467b900a2 | ||
|
0a6b5e2c17 | ||
|
d991869498 | ||
|
dd03118098 | ||
|
57304ac375 | ||
|
bab4904492 | ||
|
d88d148d89 | ||
|
3692051561 | ||
|
50aaf4667f | ||
|
7e35650398 | ||
|
c49f6290eb | ||
|
fa1cc2d05a | ||
|
f10feb2e70 | ||
|
e8ff4c8e56 | ||
|
7060bdf04b | ||
|
e0a197650a | ||
|
c913bdfc98 | ||
|
bfb6cc5f2c | ||
|
ac4f53a3a2 | ||
|
1c84d505c8 | ||
|
909d0d5e88 | ||
|
e5565a7e4a | ||
|
19615a2c37 | ||
|
c1a0633a12 | ||
|
de50eff6ac | ||
|
f8f40f15da | ||
|
61db14bcbe | ||
|
720d1f8f3d | ||
|
b746a931a5 | ||
|
abb8f5837e | ||
|
a9e40a3d80 | ||
|
17122df80d | ||
|
852c82435d | ||
|
4b0e3eb1fb | ||
|
19cfe64455 | ||
|
04bbc57690 | ||
|
8698cd3281 | ||
|
1d2175f73c |
54
.rubocop.yml
54
.rubocop.yml
@@ -1,14 +1,60 @@
|
|||||||
Rails:
|
Rails:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
Metrics/LineLength:
|
|
||||||
Enabled: false
|
|
||||||
|
|
||||||
Style/PerlBackrefs:
|
Style/PerlBackrefs:
|
||||||
AutoCorrect: false
|
AutoCorrect: false
|
||||||
|
|
||||||
Style/ClassAndModuleChildren:
|
Style/ClassAndModuleChildren:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Documentation:
|
Metrics/BlockNesting:
|
||||||
|
Max: 2
|
||||||
|
|
||||||
|
Metrics/LineLength:
|
||||||
|
AllowURI: true
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/MethodLength:
|
||||||
|
CountComments: false
|
||||||
|
Max: 10
|
||||||
|
|
||||||
|
Metrics/ModuleLength:
|
||||||
|
Max: 100
|
||||||
|
|
||||||
|
Metrics/ParameterLists:
|
||||||
|
Max: 4
|
||||||
|
CountKeywordArgs: true
|
||||||
|
|
||||||
|
Style/AccessModifierIndentation:
|
||||||
|
EnforcedStyle: indent
|
||||||
|
|
||||||
|
Style/CollectionMethods:
|
||||||
|
Enabled: true
|
||||||
|
PreferredMethods:
|
||||||
|
find_all: 'select'
|
||||||
|
|
||||||
|
Style/Documentation:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/DoubleNegation:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/FrozenStringLiteralComment:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/SpaceInsideHashLiteralBraces:
|
||||||
|
EnforcedStyle: space
|
||||||
|
|
||||||
|
Style/TrailingCommaInLiteral:
|
||||||
|
EnforcedStyleForMultiline: 'comma'
|
||||||
|
|
||||||
|
Style/RegexpLiteral:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
AllCops:
|
||||||
|
TargetRubyVersion: 2.2
|
||||||
|
Exclude:
|
||||||
|
- 'spec/**/*'
|
||||||
|
- 'db/**/*'
|
||||||
|
- 'app/views/**/*'
|
||||||
|
- 'config/**/*'
|
||||||
|
6
Gemfile
6
Gemfile
@@ -4,7 +4,6 @@ gem 'rails', '5.0.0.1'
|
|||||||
gem 'sass-rails', '~> 5.0'
|
gem 'sass-rails', '~> 5.0'
|
||||||
gem 'uglifier', '>= 1.3.0'
|
gem 'uglifier', '>= 1.3.0'
|
||||||
gem 'coffee-rails', '~> 4.1.0'
|
gem 'coffee-rails', '~> 4.1.0'
|
||||||
gem 'therubyracer', platforms: :ruby
|
|
||||||
gem 'jquery-rails'
|
gem 'jquery-rails'
|
||||||
gem 'jbuilder', '~> 2.0'
|
gem 'jbuilder', '~> 2.0'
|
||||||
gem 'sdoc', '~> 0.4.0', group: :doc
|
gem 'sdoc', '~> 0.4.0', group: :doc
|
||||||
@@ -12,11 +11,13 @@ gem 'puma'
|
|||||||
|
|
||||||
gem 'hamlit-rails'
|
gem 'hamlit-rails'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
|
gem 'pghero'
|
||||||
gem 'dotenv-rails'
|
gem 'dotenv-rails'
|
||||||
gem 'font-awesome-rails'
|
gem 'font-awesome-rails'
|
||||||
|
|
||||||
gem 'paperclip', '~> 4.3'
|
gem 'paperclip', '~> 4.3'
|
||||||
gem 'paperclip-av-transcoder'
|
gem 'paperclip-av-transcoder'
|
||||||
|
gem 'aws-sdk', '< 2.0'
|
||||||
|
|
||||||
gem 'http'
|
gem 'http'
|
||||||
gem 'httplog'
|
gem 'httplog'
|
||||||
@@ -37,12 +38,14 @@ gem 'htmlentities'
|
|||||||
gem 'simple_form'
|
gem 'simple_form'
|
||||||
gem 'will_paginate'
|
gem 'will_paginate'
|
||||||
gem 'rack-attack'
|
gem 'rack-attack'
|
||||||
|
gem 'rack-cors', require: 'rack/cors'
|
||||||
gem 'sidekiq'
|
gem 'sidekiq'
|
||||||
gem 'ledermann-rails-settings'
|
gem 'ledermann-rails-settings'
|
||||||
gem 'neography'
|
gem 'neography'
|
||||||
|
|
||||||
gem 'react-rails'
|
gem 'react-rails'
|
||||||
gem 'browserify-rails'
|
gem 'browserify-rails'
|
||||||
|
gem 'autoprefixer-rails'
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem 'rspec-rails'
|
gem 'rspec-rails'
|
||||||
@@ -69,4 +72,5 @@ end
|
|||||||
group :production do
|
group :production do
|
||||||
gem 'rails_12factor'
|
gem 'rails_12factor'
|
||||||
gem 'lograge'
|
gem 'lograge'
|
||||||
|
gem 'redis-rails'
|
||||||
end
|
end
|
||||||
|
39
Gemfile.lock
39
Gemfile.lock
@@ -42,8 +42,15 @@ GEM
|
|||||||
addressable (2.4.0)
|
addressable (2.4.0)
|
||||||
arel (7.1.1)
|
arel (7.1.1)
|
||||||
ast (2.3.0)
|
ast (2.3.0)
|
||||||
|
autoprefixer-rails (6.5.0.2)
|
||||||
|
execjs
|
||||||
av (0.9.0)
|
av (0.9.0)
|
||||||
cocaine (~> 0.5.3)
|
cocaine (~> 0.5.3)
|
||||||
|
aws-sdk (1.66.0)
|
||||||
|
aws-sdk-v1 (= 1.66.0)
|
||||||
|
aws-sdk-v1 (1.66.0)
|
||||||
|
json (~> 1.4)
|
||||||
|
nokogiri (>= 1.4.4)
|
||||||
babel-source (5.8.35)
|
babel-source (5.8.35)
|
||||||
babel-transpiler (0.7.0)
|
babel-transpiler (0.7.0)
|
||||||
babel-source (>= 4.0, < 6)
|
babel-source (>= 4.0, < 6)
|
||||||
@@ -150,7 +157,6 @@ GEM
|
|||||||
activerecord (>= 3.1)
|
activerecord (>= 3.1)
|
||||||
letter_opener (1.4.1)
|
letter_opener (1.4.1)
|
||||||
launchy (~> 2.2)
|
launchy (~> 2.2)
|
||||||
libv8 (3.16.14.15)
|
|
||||||
link_header (0.0.8)
|
link_header (0.0.8)
|
||||||
lograge (0.4.1)
|
lograge (0.4.1)
|
||||||
actionpack (>= 4, < 5.1)
|
actionpack (>= 4, < 5.1)
|
||||||
@@ -197,6 +203,8 @@ GEM
|
|||||||
parser (2.3.1.2)
|
parser (2.3.1.2)
|
||||||
ast (~> 2.2)
|
ast (~> 2.2)
|
||||||
pg (0.18.4)
|
pg (0.18.4)
|
||||||
|
pghero (1.6.2)
|
||||||
|
activerecord
|
||||||
powerpack (0.1.1)
|
powerpack (0.1.1)
|
||||||
pry (0.10.4)
|
pry (0.10.4)
|
||||||
coderay (~> 1.1.0)
|
coderay (~> 1.1.0)
|
||||||
@@ -205,11 +213,12 @@ GEM
|
|||||||
pry-rails (0.3.4)
|
pry-rails (0.3.4)
|
||||||
pry (>= 0.9.10)
|
pry (>= 0.9.10)
|
||||||
puma (3.6.0)
|
puma (3.6.0)
|
||||||
rabl (0.13.0)
|
rabl (0.13.1)
|
||||||
activesupport (>= 2.3.14)
|
activesupport (>= 2.3.14)
|
||||||
rack (2.0.1)
|
rack (2.0.1)
|
||||||
rack-attack (5.0.1)
|
rack-attack (5.0.1)
|
||||||
rack
|
rack
|
||||||
|
rack-cors (0.4.0)
|
||||||
rack-protection (1.5.3)
|
rack-protection (1.5.3)
|
||||||
rack
|
rack
|
||||||
rack-test (0.6.3)
|
rack-test (0.6.3)
|
||||||
@@ -256,7 +265,22 @@ GEM
|
|||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
tilt
|
tilt
|
||||||
redis (3.3.1)
|
redis (3.3.1)
|
||||||
ref (2.0.0)
|
redis-actionpack (5.0.0)
|
||||||
|
actionpack (>= 4.0.0, < 6)
|
||||||
|
redis-rack (~> 2.0.0.pre)
|
||||||
|
redis-store (~> 1.2.0.pre)
|
||||||
|
redis-activesupport (5.0.1)
|
||||||
|
activesupport (>= 3, < 6)
|
||||||
|
redis-store (~> 1.2.0)
|
||||||
|
redis-rack (2.0.0)
|
||||||
|
rack (~> 2.0)
|
||||||
|
redis-store (~> 1.2.0)
|
||||||
|
redis-rails (5.0.1)
|
||||||
|
redis-actionpack (~> 5.0.0)
|
||||||
|
redis-activesupport (~> 5.0.0)
|
||||||
|
redis-store (~> 1.2.0)
|
||||||
|
redis-store (1.2.0)
|
||||||
|
redis (>= 2.2)
|
||||||
responders (2.3.0)
|
responders (2.3.0)
|
||||||
railties (>= 4.2.0, < 5.1)
|
railties (>= 4.2.0, < 5.1)
|
||||||
rspec (3.5.0)
|
rspec (3.5.0)
|
||||||
@@ -324,9 +348,6 @@ GEM
|
|||||||
activesupport (>= 4.0)
|
activesupport (>= 4.0)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
temple (0.7.7)
|
temple (0.7.7)
|
||||||
therubyracer (0.12.2)
|
|
||||||
libv8 (~> 3.16.14.0)
|
|
||||||
ref
|
|
||||||
thor (0.19.1)
|
thor (0.19.1)
|
||||||
thread_safe (0.3.5)
|
thread_safe (0.3.5)
|
||||||
tilt (2.0.5)
|
tilt (2.0.5)
|
||||||
@@ -356,6 +377,8 @@ PLATFORMS
|
|||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
active_record_query_trace
|
active_record_query_trace
|
||||||
addressable
|
addressable
|
||||||
|
autoprefixer-rails
|
||||||
|
aws-sdk (< 2.0)
|
||||||
better_errors
|
better_errors
|
||||||
binding_of_caller
|
binding_of_caller
|
||||||
browserify-rails
|
browserify-rails
|
||||||
@@ -387,15 +410,18 @@ DEPENDENCIES
|
|||||||
paperclip (~> 4.3)
|
paperclip (~> 4.3)
|
||||||
paperclip-av-transcoder
|
paperclip-av-transcoder
|
||||||
pg
|
pg
|
||||||
|
pghero
|
||||||
pry-rails
|
pry-rails
|
||||||
puma
|
puma
|
||||||
rabl
|
rabl
|
||||||
rack-attack
|
rack-attack
|
||||||
|
rack-cors
|
||||||
rails (= 5.0.0.1)
|
rails (= 5.0.0.1)
|
||||||
rails_12factor
|
rails_12factor
|
||||||
rails_autolink
|
rails_autolink
|
||||||
react-rails
|
react-rails
|
||||||
redis (~> 3.2)
|
redis (~> 3.2)
|
||||||
|
redis-rails
|
||||||
rspec-rails
|
rspec-rails
|
||||||
rspec-sidekiq
|
rspec-sidekiq
|
||||||
rubocop
|
rubocop
|
||||||
@@ -404,7 +430,6 @@ DEPENDENCIES
|
|||||||
sidekiq
|
sidekiq
|
||||||
simple_form
|
simple_form
|
||||||
simplecov
|
simplecov
|
||||||
therubyracer
|
|
||||||
uglifier (>= 1.3.0)
|
uglifier (>= 1.3.0)
|
||||||
webmock
|
webmock
|
||||||
will_paginate
|
will_paginate
|
||||||
|
18
README.md
18
README.md
@@ -7,11 +7,13 @@ Mastodon
|
|||||||
[travis]: https://travis-ci.org/Gargron/mastodon
|
[travis]: https://travis-ci.org/Gargron/mastodon
|
||||||
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
|
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
|
||||||
|
|
||||||
Mastodon is a federated microblogging engine. An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
|
||||||
|
|
||||||
|
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||||
|
|
||||||
Click on the screenshot to watch a demo of the UI:
|
Click on the screenshot to watch a demo of the UI:
|
||||||
|
|
||||||
[][youtube_demo]
|
[][youtube_demo]
|
||||||
|
|
||||||
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
|
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
|
||||||
|
|
||||||
@@ -21,12 +23,13 @@ If you would like, you can [support the development of this project on Patreon][
|
|||||||
|
|
||||||
[patreon]: https://www.patreon.com/user?u=619786
|
[patreon]: https://www.patreon.com/user?u=619786
|
||||||
|
|
||||||
**Current status of the project is early development**
|
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
|
- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
|
||||||
|
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
|
||||||
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
|
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
|
||||||
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
|
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
|
||||||
|
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -113,3 +116,10 @@ Which will re-create the updated containers, leaving databases and data as is. D
|
|||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
|
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
|
||||||
|
|
||||||
|
### Extra credits
|
||||||
|
|
||||||
|
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
|
||||||
|
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
|
||||||
|
|
||||||
|

|
||||||
|
BIN
app/assets/images/screenshot.png
Normal file
BIN
app/assets/images/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 346 KiB |
@@ -1,5 +1,4 @@
|
|||||||
import api from '../api'
|
import api from '../api'
|
||||||
import axios from 'axios';
|
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
|
||||||
@@ -32,6 +31,18 @@ export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'
|
|||||||
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
|
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
|
||||||
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
|
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
|
||||||
|
|
||||||
|
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
|
||||||
|
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
|
||||||
|
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
|
||||||
|
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
|
||||||
|
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
|
||||||
|
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
|
||||||
|
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
|
||||||
|
|
||||||
export function setAccountSelf(account) {
|
export function setAccountSelf(account) {
|
||||||
return {
|
return {
|
||||||
type: ACCOUNT_SET_SELF,
|
type: ACCOUNT_SET_SELF,
|
||||||
@@ -41,24 +52,32 @@ export function setAccountSelf(account) {
|
|||||||
|
|
||||||
export function fetchAccount(id) {
|
export function fetchAccount(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const boundApi = api(getState);
|
|
||||||
|
|
||||||
dispatch(fetchAccountRequest(id));
|
dispatch(fetchAccountRequest(id));
|
||||||
|
|
||||||
axios.all([boundApi.get(`/api/v1/accounts/${id}`), boundApi.get(`/api/v1/accounts/relationships?id=${id}`)]).then(values => {
|
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
|
||||||
dispatch(fetchAccountSuccess(values[0].data, values[1].data[0]));
|
dispatch(fetchAccountSuccess(response.data));
|
||||||
|
dispatch(fetchRelationships([id]));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchAccountFail(id, error));
|
dispatch(fetchAccountFail(id, error));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchAccountTimeline(id) {
|
export function fetchAccountTimeline(id, replace = false) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(fetchAccountTimelineRequest(id));
|
dispatch(fetchAccountTimelineRequest(id));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/accounts/${id}/statuses`).then(response => {
|
const ids = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
|
||||||
dispatch(fetchAccountTimelineSuccess(id, response.data));
|
const newestId = ids.size > 0 ? ids.first() : null;
|
||||||
|
|
||||||
|
let params = '';
|
||||||
|
|
||||||
|
if (newestId !== null && !replace) {
|
||||||
|
params = `?since_id=${newestId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
|
||||||
|
dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchAccountTimelineFail(id, error));
|
dispatch(fetchAccountTimelineFail(id, error));
|
||||||
});
|
});
|
||||||
@@ -86,11 +105,10 @@ export function fetchAccountRequest(id) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchAccountSuccess(account, relationship) {
|
export function fetchAccountSuccess(account) {
|
||||||
return {
|
return {
|
||||||
type: ACCOUNT_FETCH_SUCCESS,
|
type: ACCOUNT_FETCH_SUCCESS,
|
||||||
account: account,
|
account: account
|
||||||
relationship: relationship
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,11 +193,12 @@ export function fetchAccountTimelineRequest(id) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchAccountTimelineSuccess(id, statuses) {
|
export function fetchAccountTimelineSuccess(id, statuses, replace) {
|
||||||
return {
|
return {
|
||||||
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||||
id: id,
|
id: id,
|
||||||
statuses: statuses
|
statuses: statuses,
|
||||||
|
replace: replace
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -279,3 +298,108 @@ export function unblockAccountFail(error) {
|
|||||||
error: error
|
error: error
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function fetchFollowers(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchFollowersRequest(id));
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
|
||||||
|
dispatch(fetchFollowersSuccess(id, response.data));
|
||||||
|
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchFollowersFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowersRequest(id) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWERS_FETCH_REQUEST,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowersSuccess(id, accounts) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWERS_FETCH_SUCCESS,
|
||||||
|
id: id,
|
||||||
|
accounts: accounts
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowersFail(id, error) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWERS_FETCH_FAIL,
|
||||||
|
id: id,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowing(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchFollowingRequest(id));
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
|
||||||
|
dispatch(fetchFollowingSuccess(id, response.data));
|
||||||
|
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchFollowingFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowingRequest(id) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWING_FETCH_REQUEST,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowingSuccess(id, accounts) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWING_FETCH_SUCCESS,
|
||||||
|
id: id,
|
||||||
|
accounts: accounts
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFollowingFail(id, error) {
|
||||||
|
return {
|
||||||
|
type: FOLLOWING_FETCH_FAIL,
|
||||||
|
id: id,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchRelationships(account_ids) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchRelationshipsRequest(account_ids));
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||||
|
dispatch(fetchRelationshipsSuccess(response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchRelationshipsFail(error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchRelationshipsRequest(ids) {
|
||||||
|
return {
|
||||||
|
type: RELATIONSHIPS_FETCH_REQUEST,
|
||||||
|
ids: ids
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchRelationshipsSuccess(relationships) {
|
||||||
|
return {
|
||||||
|
type: RELATIONSHIPS_FETCH_SUCCESS,
|
||||||
|
relationships: relationships
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchRelationshipsFail(error) {
|
||||||
|
return {
|
||||||
|
type: RELATIONSHIPS_FETCH_FAIL,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@@ -1,17 +1,23 @@
|
|||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
|
import { updateTimeline } from './timelines';
|
||||||
|
|
||||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||||
|
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
||||||
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||||
|
|
||||||
|
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||||
|
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||||
|
|
||||||
export function changeCompose(text) {
|
export function changeCompose(text) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_CHANGE,
|
type: COMPOSE_CHANGE,
|
||||||
@@ -32,6 +38,13 @@ export function cancelReplyCompose() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function mentionCompose(account) {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_MENTION,
|
||||||
|
account: account
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function submitCompose() {
|
export function submitCompose() {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(submitComposeRequest());
|
dispatch(submitComposeRequest());
|
||||||
@@ -42,6 +55,7 @@ export function submitCompose() {
|
|||||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
|
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
dispatch(submitComposeSuccess(response.data));
|
dispatch(submitComposeSuccess(response.data));
|
||||||
|
dispatch(updateTimeline('home', response.data));
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(submitComposeFail(error));
|
dispatch(submitComposeFail(error));
|
||||||
});
|
});
|
||||||
@@ -121,3 +135,27 @@ export function undoUploadCompose(media_id) {
|
|||||||
media_id: media_id
|
media_id: media_id
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function clearComposeSuggestions() {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_SUGGESTIONS_CLEAR
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchComposeSuggestions(token) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const loadedCandidates = getState().get('accounts').filter(item => item.get('acct').toLowerCase().slice(0, token.length) === token).map(item => ({
|
||||||
|
label: item.get('acct'),
|
||||||
|
completion: item.get('acct').slice(token.length)
|
||||||
|
})).toList().toJS();
|
||||||
|
|
||||||
|
dispatch(readyComposeSuggestions(loadedCandidates));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readyComposeSuggestions(accounts) {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_SUGGESTIONS_READY,
|
||||||
|
accounts
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@@ -16,6 +16,14 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
|||||||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
||||||
|
|
||||||
|
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
|
||||||
|
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
|
||||||
|
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
|
||||||
|
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||||
|
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||||
|
|
||||||
export function reblog(status) {
|
export function reblog(status) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(reblogRequest(status));
|
dispatch(reblogRequest(status));
|
||||||
@@ -157,3 +165,71 @@ export function unfavouriteFail(status, error) {
|
|||||||
error: error
|
error: error
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function fetchReblogs(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchReblogsRequest(id));
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
|
||||||
|
dispatch(fetchReblogsSuccess(id, response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchReblogsFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchReblogsRequest(id) {
|
||||||
|
return {
|
||||||
|
type: REBLOGS_FETCH_REQUEST,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchReblogsSuccess(id, accounts) {
|
||||||
|
return {
|
||||||
|
type: REBLOGS_FETCH_SUCCESS,
|
||||||
|
id,
|
||||||
|
accounts
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchReblogsFail(id, error) {
|
||||||
|
return {
|
||||||
|
type: REBLOGS_FETCH_FAIL,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFavourites(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchFavouritesRequest(id));
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
|
||||||
|
dispatch(fetchFavouritesSuccess(id, response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchFavouritesFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFavouritesRequest(id) {
|
||||||
|
return {
|
||||||
|
type: FAVOURITES_FETCH_REQUEST,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFavouritesSuccess(id, accounts) {
|
||||||
|
return {
|
||||||
|
type: FAVOURITES_FETCH_SUCCESS,
|
||||||
|
id,
|
||||||
|
accounts
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchFavouritesFail(id, error) {
|
||||||
|
return {
|
||||||
|
type: FAVOURITES_FETCH_FAIL,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
15
app/assets/javascripts/components/actions/modal.jsx
Normal file
15
app/assets/javascripts/components/actions/modal.jsx
Normal 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
|
||||||
|
};
|
||||||
|
};
|
@@ -1,5 +1,6 @@
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import axios from 'axios';
|
|
||||||
|
import { deleteFromTimelines } from './timelines';
|
||||||
|
|
||||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||||
@@ -9,6 +10,10 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
|||||||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||||
|
|
||||||
|
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
|
||||||
|
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
||||||
|
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
||||||
|
|
||||||
export function fetchStatusRequest(id) {
|
export function fetchStatusRequest(id) {
|
||||||
return {
|
return {
|
||||||
type: STATUS_FETCH_REQUEST,
|
type: STATUS_FETCH_REQUEST,
|
||||||
@@ -18,12 +23,11 @@ export function fetchStatusRequest(id) {
|
|||||||
|
|
||||||
export function fetchStatus(id) {
|
export function fetchStatus(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const boundApi = api(getState);
|
|
||||||
|
|
||||||
dispatch(fetchStatusRequest(id));
|
dispatch(fetchStatusRequest(id));
|
||||||
|
|
||||||
axios.all([boundApi.get(`/api/v1/statuses/${id}`), boundApi.get(`/api/v1/statuses/${id}/context`)]).then(values => {
|
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
|
||||||
dispatch(fetchStatusSuccess(values[0].data, values[1].data));
|
dispatch(fetchStatusSuccess(response.data));
|
||||||
|
dispatch(fetchContext(id));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchStatusFail(id, error));
|
dispatch(fetchStatusFail(id, error));
|
||||||
});
|
});
|
||||||
@@ -52,6 +56,7 @@ export function deleteStatus(id) {
|
|||||||
|
|
||||||
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
|
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
|
||||||
dispatch(deleteStatusSuccess(id));
|
dispatch(deleteStatusSuccess(id));
|
||||||
|
dispatch(deleteFromTimelines(id));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(deleteStatusFail(id, error));
|
dispatch(deleteStatusFail(id, error));
|
||||||
});
|
});
|
||||||
@@ -79,3 +84,40 @@ export function deleteStatusFail(id, error) {
|
|||||||
error: error
|
error: error
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function fetchContext(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(fetchContextRequest(id));
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||||
|
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(fetchContextFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchContextRequest(id) {
|
||||||
|
return {
|
||||||
|
type: CONTEXT_FETCH_REQUEST,
|
||||||
|
id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchContextSuccess(id, ancestors, descendants) {
|
||||||
|
return {
|
||||||
|
type: CONTEXT_FETCH_SUCCESS,
|
||||||
|
id,
|
||||||
|
ancestors,
|
||||||
|
descendants,
|
||||||
|
statuses: ancestors.concat(descendants)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchContextFail(id, error) {
|
||||||
|
return {
|
||||||
|
type: CONTEXT_FETCH_FAIL,
|
||||||
|
id,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@@ -22,10 +22,10 @@ export function fetchSuggestionsRequest() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchSuggestionsSuccess(suggestions) {
|
export function fetchSuggestionsSuccess(accounts) {
|
||||||
return {
|
return {
|
||||||
type: SUGGESTIONS_FETCH_SUCCESS,
|
type: SUGGESTIONS_FETCH_SUCCESS,
|
||||||
suggestions: suggestions
|
accounts: accounts
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||||
@@ -11,26 +12,39 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
|
|||||||
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
|
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
|
||||||
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
||||||
|
|
||||||
export function refreshTimelineSuccess(timeline, statuses) {
|
export function refreshTimelineSuccess(timeline, statuses, replace) {
|
||||||
return {
|
return {
|
||||||
type: TIMELINE_REFRESH_SUCCESS,
|
type: TIMELINE_REFRESH_SUCCESS,
|
||||||
timeline: timeline,
|
timeline: timeline,
|
||||||
statuses: statuses
|
statuses: statuses,
|
||||||
|
replace: replace
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function updateTimeline(timeline, status) {
|
export function updateTimeline(timeline, status) {
|
||||||
return {
|
return (dispatch, getState) => {
|
||||||
|
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
|
||||||
|
|
||||||
|
dispatch({
|
||||||
type: TIMELINE_UPDATE,
|
type: TIMELINE_UPDATE,
|
||||||
timeline: timeline,
|
timeline,
|
||||||
status: status
|
status,
|
||||||
|
references
|
||||||
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function deleteFromTimelines(id) {
|
export function deleteFromTimelines(id) {
|
||||||
return {
|
return (dispatch, getState) => {
|
||||||
|
const accountId = getState().getIn(['statuses', id, 'account']);
|
||||||
|
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
type: TIMELINE_DELETE,
|
type: TIMELINE_DELETE,
|
||||||
id: id
|
id,
|
||||||
|
accountId,
|
||||||
|
references
|
||||||
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,12 +55,26 @@ export function refreshTimelineRequest(timeline) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function refreshTimeline(timeline) {
|
export function refreshTimeline(timeline, replace = false, id = null) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(refreshTimelineRequest(timeline));
|
dispatch(refreshTimelineRequest(timeline));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) {
|
const ids = getState().getIn(['timelines', timeline], Immutable.List());
|
||||||
dispatch(refreshTimelineSuccess(timeline, response.data));
|
const newestId = ids.size > 0 ? ids.first() : null;
|
||||||
|
|
||||||
|
let params = '';
|
||||||
|
let path = timeline;
|
||||||
|
|
||||||
|
if (newestId !== null && !replace) {
|
||||||
|
params = `?since_id=${newestId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
path = `${path}/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
|
||||||
|
dispatch(refreshTimelineSuccess(timeline, response.data, replace));
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(refreshTimelineFail(timeline, error));
|
dispatch(refreshTimelineFail(timeline, error));
|
||||||
});
|
});
|
||||||
@@ -61,13 +89,19 @@ export function refreshTimelineFail(timeline, error) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function expandTimeline(timeline) {
|
export function expandTimeline(timeline, id = null) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const lastId = getState().getIn(['timelines', timeline]).last();
|
const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last();
|
||||||
|
|
||||||
dispatch(expandTimelineRequest(timeline));
|
dispatch(expandTimelineRequest(timeline));
|
||||||
|
|
||||||
api(getState).get(`/api/v1/statuses/${timeline}?max_id=${lastId}`).then(response => {
|
let path = timeline;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
path = `${path}/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
|
||||||
dispatch(expandTimelineSuccess(timeline, response.data));
|
dispatch(expandTimelineSuccess(timeline, response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandTimelineFail(timeline, error));
|
dispatch(expandTimelineFail(timeline, error));
|
||||||
|
@@ -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;
|
@@ -28,16 +28,21 @@ const IconButton = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
const style = {
|
const style = {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
border: 'none',
|
||||||
|
padding: '0',
|
||||||
|
background: 'transparent',
|
||||||
fontSize: `${this.props.size}px`,
|
fontSize: `${this.props.size}px`,
|
||||||
width: `${this.props.size}px`,
|
width: `${this.props.size * 1.28571429}px`,
|
||||||
height: `${this.props.size}px`,
|
height: `${this.props.size}px`,
|
||||||
lineHeight: `${this.props.size}px`
|
lineHeight: `${this.props.size}px`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
...this.props.style
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
|
<button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}>
|
||||||
<i className={`fa fa-fw fa-${this.props.icon}`}></i>
|
<i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
53
app/assets/javascripts/components/components/lightbox.jsx
Normal file
53
app/assets/javascripts/components/components/lightbox.jsx
Normal 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;
|
@@ -5,11 +5,21 @@ const MediaGallery = React.createClass({
|
|||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
media: ImmutablePropTypes.list.isRequired,
|
media: ImmutablePropTypes.list.isRequired,
|
||||||
height: React.PropTypes.number.isRequired
|
height: React.PropTypes.number.isRequired,
|
||||||
|
onOpenMedia: React.PropTypes.func.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
handleClick (url, e) {
|
||||||
|
if (e.button === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.onOpenMedia(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
var children = this.props.media.take(4);
|
var children = this.props.media.take(4);
|
||||||
var size = children.size;
|
var size = children.size;
|
||||||
@@ -64,7 +74,11 @@ const MediaGallery = React.createClass({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <a key={attachment.get('id')} href={attachment.get('url')} target='_blank' style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', textDecoration: 'none', border: 'none', display: 'block', width: `${width}%`, height: `${height}%`, background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover', cursor: 'zoom-in' }} />;
|
return (
|
||||||
|
<div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
|
||||||
|
<a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -21,35 +21,28 @@ moment.updateLocale('en', {
|
|||||||
|
|
||||||
const RelativeTimestamp = React.createClass({
|
const RelativeTimestamp = React.createClass({
|
||||||
|
|
||||||
getInitialState () {
|
|
||||||
return {
|
|
||||||
text: ''
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
timestamp: React.PropTypes.string.isRequired
|
timestamp: React.PropTypes.string.isRequired,
|
||||||
|
now: React.PropTypes.any
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
componentWillMount () {
|
|
||||||
this._updateMomentText();
|
|
||||||
this.interval = setInterval(this._updateMomentText, 60000);
|
|
||||||
},
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
},
|
|
||||||
|
|
||||||
_updateMomentText () {
|
|
||||||
this.setState({ text: moment(this.props.timestamp).fromNow() });
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
const timestamp = moment(this.props.timestamp);
|
||||||
|
const now = this.props.now;
|
||||||
|
|
||||||
|
let string = '';
|
||||||
|
|
||||||
|
if (timestamp.isAfter(now)) {
|
||||||
|
string = 'Just now';
|
||||||
|
} else {
|
||||||
|
string = timestamp.from(now);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{this.state.text}
|
{string}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -15,13 +15,15 @@ const Status = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map,
|
||||||
wrapped: React.PropTypes.bool,
|
wrapped: React.PropTypes.bool,
|
||||||
onReply: React.PropTypes.func,
|
onReply: React.PropTypes.func,
|
||||||
onFavourite: React.PropTypes.func,
|
onFavourite: React.PropTypes.func,
|
||||||
onReblog: React.PropTypes.func,
|
onReblog: React.PropTypes.func,
|
||||||
onDelete: React.PropTypes.func,
|
onDelete: React.PropTypes.func,
|
||||||
me: React.PropTypes.number
|
onOpenMedia: React.PropTypes.func,
|
||||||
|
me: React.PropTypes.number,
|
||||||
|
now: React.PropTypes.any
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -42,7 +44,11 @@ const Status = React.createClass({
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
let media = '';
|
let media = '';
|
||||||
let { status, ...other } = this.props;
|
const { status, now, ...other } = this.props;
|
||||||
|
|
||||||
|
if (status === null) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||||
let displayName = status.getIn(['account', 'display_name']);
|
let displayName = status.getIn(['account', 'display_name']);
|
||||||
@@ -67,7 +73,7 @@ const Status = React.createClass({
|
|||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
|
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />;
|
||||||
} else {
|
} else {
|
||||||
media = <MediaGallery media={status.get('media_attachments')} height={110} />;
|
media = <MediaGallery media={status.get('media_attachments')} height={110} onOpenMedia={this.props.onOpenMedia} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +81,7 @@ const Status = React.createClass({
|
|||||||
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
|
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
|
||||||
<div style={{ fontSize: '15px' }}>
|
<div style={{ fontSize: '15px' }}>
|
||||||
<div style={{ float: 'right', fontSize: '14px' }}>
|
<div style={{ float: 'right', fontSize: '14px' }}>
|
||||||
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} now={now} /></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>
|
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>
|
||||||
|
@@ -9,7 +9,8 @@ const StatusActionBar = React.createClass({
|
|||||||
onReply: React.PropTypes.func,
|
onReply: React.PropTypes.func,
|
||||||
onFavourite: React.PropTypes.func,
|
onFavourite: React.PropTypes.func,
|
||||||
onReblog: React.PropTypes.func,
|
onReblog: React.PropTypes.func,
|
||||||
onDelete: React.PropTypes.func
|
onDelete: React.PropTypes.func,
|
||||||
|
onMention: React.PropTypes.func
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -30,12 +31,18 @@ const StatusActionBar = React.createClass({
|
|||||||
this.props.onDelete(this.props.status);
|
this.props.onDelete(this.props.status);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleMentionClick () {
|
||||||
|
this.props.onMention(this.props.status.get('account'));
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, me } = this.props;
|
const { status, me } = this.props;
|
||||||
let menu = [];
|
let menu = [];
|
||||||
|
|
||||||
if (status.getIn(['account', 'id']) === me) {
|
if (status.getIn(['account', 'id']) === me) {
|
||||||
menu.push({ text: 'Delete', action: this.handleDeleteClick });
|
menu.push({ text: 'Delete', action: this.handleDeleteClick });
|
||||||
|
} else {
|
||||||
|
menu.push({ text: 'Mention', action: this.handleMentionClick });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -1,5 +1,10 @@
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import emojione from 'emojione';
|
||||||
|
|
||||||
|
emojione.imageType = 'png';
|
||||||
|
emojione.sprites = false;
|
||||||
|
emojione.imagePathPNG = '/emoji/';
|
||||||
|
|
||||||
const StatusContent = React.createClass({
|
const StatusContent = React.createClass({
|
||||||
|
|
||||||
@@ -23,11 +28,14 @@ const StatusContent = React.createClass({
|
|||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||||
|
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||||
|
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||||
} else {
|
} else {
|
||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank');
|
||||||
link.setAttribute('rel', 'noopener');
|
link.setAttribute('rel', 'noopener');
|
||||||
link.addEventListener('click', this.onNormalClick, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
link.addEventListener('click', this.onNormalClick, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -36,8 +44,15 @@ const StatusContent = React.createClass({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.context.router.push(`/accounts/${mention.get('id')}`);
|
this.context.router.push(`/accounts/${mention.get('id')}`);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
e.stopPropagation();
|
onHashtagClick (hashtag, e) {
|
||||||
|
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||||
|
|
||||||
|
if (e.button === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.push(`/statuses/tag/${hashtag}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onNormalClick (e) {
|
onNormalClick (e) {
|
||||||
@@ -45,7 +60,7 @@ const StatusContent = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const content = { __html: this.props.status.get('content') };
|
const content = { __html: emojione.unicodeToImage(this.props.status.get('content')) };
|
||||||
return <div className='status__content' dangerouslySetInnerHTML={content} />;
|
return <div className='status__content' dangerouslySetInnerHTML={content} />;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@@ -1,41 +1,82 @@
|
|||||||
import Status from './status';
|
import Status from './status';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import { ScrollContainer } from 'react-router-scroll';
|
||||||
|
import StatusContainer from '../containers/status_container';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
const StatusList = React.createClass({
|
const StatusList = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
statuses: ImmutablePropTypes.list.isRequired,
|
statusIds: ImmutablePropTypes.list.isRequired,
|
||||||
onReply: React.PropTypes.func,
|
|
||||||
onReblog: React.PropTypes.func,
|
|
||||||
onFavourite: React.PropTypes.func,
|
|
||||||
onDelete: React.PropTypes.func,
|
|
||||||
onScrollToBottom: React.PropTypes.func,
|
onScrollToBottom: React.PropTypes.func,
|
||||||
me: React.PropTypes.number
|
trackScroll: React.PropTypes.bool
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultProps () {
|
||||||
|
return {
|
||||||
|
trackScroll: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialState () {
|
||||||
|
return {
|
||||||
|
now: moment()
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this._interval = setInterval(() => this.setState({ now: moment() }), 60000);
|
||||||
|
},
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
clearInterval(this._interval);
|
||||||
|
},
|
||||||
|
|
||||||
handleScroll (e) {
|
handleScroll (e) {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
|
|
||||||
|
this._oldScrollPosition = scrollHeight - scrollTop;
|
||||||
|
|
||||||
if (scrollTop === scrollHeight - clientHeight) {
|
if (scrollTop === scrollHeight - clientHeight) {
|
||||||
this.props.onScrollToBottom();
|
this.props.onScrollToBottom();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
componentDidUpdate (prevProps) {
|
||||||
const { statuses, onScrollToBottom, ...other } = this.props;
|
if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
|
||||||
|
const node = ReactDOM.findDOMNode(this);
|
||||||
|
|
||||||
return (
|
if (node.scrollTop > 0) {
|
||||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto', overflowX: 'hidden' }} className='scrollable' onScroll={this.handleScroll}>
|
node.scrollTop = node.scrollHeight - this._oldScrollPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { statusIds, onScrollToBottom, trackScroll } = this.props;
|
||||||
|
|
||||||
|
const scrollableArea = (
|
||||||
|
<div className='scrollable' onScroll={this.handleScroll}>
|
||||||
<div>
|
<div>
|
||||||
{statuses.map((status) => {
|
{statusIds.map((statusId) => {
|
||||||
return <Status key={status.get('id')} {...other} status={status} />;
|
return <StatusContainer key={statusId} id={statusId} now={this.state.now} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (trackScroll) {
|
||||||
|
return (
|
||||||
|
<ScrollContainer scrollKey='status-list'>
|
||||||
|
{scrollableArea}
|
||||||
|
</ScrollContainer>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return scrollableArea;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -2,6 +2,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import IconButton from './icon_button';
|
import IconButton from './icon_button';
|
||||||
|
|
||||||
|
const videoStyle = {
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: '1',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const muteStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '10px',
|
||||||
|
left: '10px',
|
||||||
|
opacity: '0.8',
|
||||||
|
zIndex: '5'
|
||||||
|
};
|
||||||
|
|
||||||
const VideoPlayer = React.createClass({
|
const VideoPlayer = React.createClass({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
@@ -28,11 +46,23 @@ const VideoPlayer = React.createClass({
|
|||||||
this.setState({ muted: !this.state.muted });
|
this.setState({ muted: !this.state.muted });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleVideoClick (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const node = ReactDOM.findDOMNode(this).querySelector('video');
|
||||||
|
|
||||||
|
if (node.paused) {
|
||||||
|
node.play();
|
||||||
|
} else {
|
||||||
|
node.pause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', top: '10px', left: '10px', opacity: '0.8' }}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
|
<div style={muteStyle}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
|
||||||
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} />
|
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -10,11 +10,13 @@ import { setAccessToken } from '../actions/meta';
|
|||||||
import { setAccountSelf } from '../actions/accounts';
|
import { setAccountSelf } from '../actions/accounts';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import {
|
import {
|
||||||
|
applyRouterMiddleware,
|
||||||
Router,
|
Router,
|
||||||
Route,
|
Route,
|
||||||
hashHistory,
|
hashHistory,
|
||||||
IndexRoute
|
IndexRoute
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
import { useScroll } from 'react-router-scroll';
|
||||||
import UI from '../features/ui';
|
import UI from '../features/ui';
|
||||||
import Account from '../features/account';
|
import Account from '../features/account';
|
||||||
import Status from '../features/status';
|
import Status from '../features/status';
|
||||||
@@ -24,6 +26,11 @@ import AccountTimeline from '../features/account_timeline';
|
|||||||
import HomeTimeline from '../features/home_timeline';
|
import HomeTimeline from '../features/home_timeline';
|
||||||
import MentionsTimeline from '../features/mentions_timeline';
|
import MentionsTimeline from '../features/mentions_timeline';
|
||||||
import Compose from '../features/compose';
|
import Compose from '../features/compose';
|
||||||
|
import Followers from '../features/followers';
|
||||||
|
import Following from '../features/following';
|
||||||
|
import Reblogs from '../features/reblogs';
|
||||||
|
import Favourites from '../features/favourites';
|
||||||
|
import HashtagTimeline from '../features/hashtag_timeline';
|
||||||
|
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
@@ -52,9 +59,9 @@ const Mastodon = React.createClass({
|
|||||||
return store.dispatch(deleteFromTimelines(data.id));
|
return store.dispatch(deleteFromTimelines(data.id));
|
||||||
case 'merge':
|
case 'merge':
|
||||||
case 'unmerge':
|
case 'unmerge':
|
||||||
return store.dispatch(refreshTimeline('home'));
|
return store.dispatch(refreshTimeline('home', true));
|
||||||
case 'block':
|
case 'block':
|
||||||
return store.dispatch(refreshTimeline('mentions'));
|
return store.dispatch(refreshTimeline('mentions', true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,16 +78,24 @@ const Mastodon = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Router history={hashHistory}>
|
<Router history={hashHistory} render={applyRouterMiddleware(useScroll())}>
|
||||||
<Route path='/' component={UI}>
|
<Route path='/' component={UI}>
|
||||||
<IndexRoute component={GettingStarted} />
|
<IndexRoute component={GettingStarted} />
|
||||||
<Route path='/statuses/new' component={Compose} />
|
<Route path='/statuses/new' component={Compose} />
|
||||||
|
|
||||||
<Route path='/statuses/home' component={HomeTimeline} />
|
<Route path='/statuses/home' component={HomeTimeline} />
|
||||||
<Route path='/statuses/mentions' component={MentionsTimeline} />
|
<Route path='/statuses/mentions' component={MentionsTimeline} />
|
||||||
<Route path='/statuses/all' component={PublicTimeline} />
|
<Route path='/statuses/all' component={PublicTimeline} />
|
||||||
|
<Route path='/statuses/tag/:id' component={HashtagTimeline} />
|
||||||
|
|
||||||
<Route path='/statuses/:statusId' component={Status} />
|
<Route path='/statuses/:statusId' component={Status} />
|
||||||
|
<Route path='/statuses/:statusId/reblogs' component={Reblogs} />
|
||||||
|
<Route path='/statuses/:statusId/favourites' component={Favourites} />
|
||||||
|
|
||||||
<Route path='/accounts/:accountId' component={Account}>
|
<Route path='/accounts/:accountId' component={Account}>
|
||||||
<IndexRoute component={AccountTimeline} />
|
<IndexRoute component={AccountTimeline} />
|
||||||
|
<Route path='/accounts/:accountId/followers' component={Followers} />
|
||||||
|
<Route path='/accounts/:accountId/following' component={Following} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
|
@@ -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)
|
||||||
|
)
|
||||||
|
);
|
@@ -1,6 +1,27 @@
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import DropdownMenu from '../../../components/dropdown_menu';
|
import DropdownMenu from '../../../components/dropdown_menu';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const outerStyle = {
|
||||||
|
borderTop: '1px solid #363c4b',
|
||||||
|
borderBottom: '1px solid #363c4b',
|
||||||
|
lineHeight: '36px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
display: 'flex'
|
||||||
|
};
|
||||||
|
|
||||||
|
const outerDropdownStyle = {
|
||||||
|
padding: '10px',
|
||||||
|
flex: '1 1 auto'
|
||||||
|
};
|
||||||
|
|
||||||
|
const outerLinksStyle = {
|
||||||
|
flex: '1 1 auto',
|
||||||
|
display: 'flex',
|
||||||
|
lineHeight: '18px'
|
||||||
|
};
|
||||||
|
|
||||||
const ActionBar = React.createClass({
|
const ActionBar = React.createClass({
|
||||||
|
|
||||||
@@ -8,7 +29,8 @@ const ActionBar = React.createClass({
|
|||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
me: React.PropTypes.number.isRequired,
|
me: React.PropTypes.number.isRequired,
|
||||||
onFollow: React.PropTypes.func.isRequired,
|
onFollow: React.PropTypes.func.isRequired,
|
||||||
onBlock: React.PropTypes.func.isRequired
|
onBlock: React.PropTypes.func.isRequired,
|
||||||
|
onMention: React.PropTypes.func.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -18,6 +40,8 @@ const ActionBar = React.createClass({
|
|||||||
|
|
||||||
let menu = [];
|
let menu = [];
|
||||||
|
|
||||||
|
menu.push({ text: 'Mention', action: this.props.onMention });
|
||||||
|
|
||||||
if (account.get('id') === me) {
|
if (account.get('id') === me) {
|
||||||
menu.push({ text: 'Edit profile', href: '/settings/profile' });
|
menu.push({ text: 'Edit profile', href: '/settings/profile' });
|
||||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||||
@@ -31,26 +55,26 @@ const ActionBar = React.createClass({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto', display: 'flex' }}>
|
<div style={outerStyle}>
|
||||||
<div style={{ flex: '1 1 auto', display: 'flex', lineHeight: '18px' }}>
|
<div style={outerDropdownStyle}>
|
||||||
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
|
<DropdownMenu items={menu} icon='bars' size={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={outerLinksStyle}>
|
||||||
|
<Link to={`/accounts/${account.get('id')}`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px', paddingRight: '5px' }}>
|
||||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
|
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Posts</span>
|
||||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
|
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('statuses_count')}</span>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
<div style={{ overflow: 'hidden', width: '80px', borderRight: '1px solid #363c4b', padding: '10px 5px' }}>
|
<Link to={`/accounts/${account.get('id')}/following`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', borderLeft: '1px solid #363c4b', padding: '10px 5px' }}>
|
||||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
|
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Follows</span>
|
||||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
|
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('following_count')}</span>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
<div style={{ overflow: 'hidden', width: '80px', padding: '10px 5px', borderRight: '1px solid #363c4b' }}>
|
<Link to={`/accounts/${account.get('id')}/followers`} style={{ textDecoration: 'none', overflow: 'hidden', width: '80px', padding: '10px 5px', borderLeft: '1px solid #363c4b' }}>
|
||||||
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
|
<span style={{ display: 'block', textTransform: 'uppercase', fontSize: '11px', color: '#616b86' }}>Followers</span>
|
||||||
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
|
<span style={{ display: 'block', fontSize: '15px', fontWeight: '500', color: '#fff' }}>{account.get('followers_count')}</span>
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '10px', flex: '1 1 auto' }}>
|
|
||||||
<DropdownMenu items={menu} icon='bars' size={24} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -24,19 +24,21 @@ const Header = React.createClass({
|
|||||||
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
|
info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}>Follows you</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const content = { __html: account.get('note') };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
|
<div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}>
|
||||||
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '30px 10px' }}>
|
<div style={{ background: 'rgba(47, 52, 65, 0.8)', padding: '20px 10px' }}>
|
||||||
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
<a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
||||||
<div style={{ width: '90px', margin: '0 auto', marginBottom: '15px' }}>
|
<div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
|
||||||
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
<img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
|
<span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }}>{displayName}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '15px' }}>@{account.get('acct')}</span>
|
<span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')}</span>
|
||||||
<p style={{ color: '#616b86', fontSize: '14px' }}>{account.get('note')}</p>
|
<div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{info}
|
{info}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -10,20 +10,28 @@ import {
|
|||||||
fetchAccountTimeline,
|
fetchAccountTimeline,
|
||||||
expandAccountTimeline
|
expandAccountTimeline
|
||||||
} from '../../actions/accounts';
|
} from '../../actions/accounts';
|
||||||
|
import { mentionCompose } from '../../actions/compose';
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
import {
|
import {
|
||||||
getAccountTimeline,
|
getAccountTimeline,
|
||||||
getAccount
|
makeGetAccount
|
||||||
} from '../../selectors';
|
} from '../../selectors';
|
||||||
import LoadingIndicator from '../../components/loading_indicator';
|
import LoadingIndicator from '../../components/loading_indicator';
|
||||||
import ActionBar from './components/action_bar';
|
import ActionBar from './components/action_bar';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
|
import ColumnBackButton from '../../components/column_back_button';
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
account: getAccount(state, Number(props.params.accountId)),
|
account: getAccount(state, Number(props.params.accountId)),
|
||||||
me: state.getIn(['timelines', 'me'])
|
me: state.getIn(['meta', 'me'])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
const Account = React.createClass({
|
const Account = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
@@ -61,6 +69,10 @@ const Account = React.createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleMention () {
|
||||||
|
this.props.dispatch(mentionCompose(this.props.account));
|
||||||
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, me } = this.props;
|
const { account, me } = this.props;
|
||||||
|
|
||||||
@@ -74,9 +86,10 @@ const Account = React.createClass({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
|
<ColumnBackButton />
|
||||||
<Header account={account} me={me} />
|
<Header account={account} me={me} />
|
||||||
|
|
||||||
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
|
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} onMention={this.handleMention} />
|
||||||
|
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</Column>
|
</Column>
|
||||||
@@ -85,4 +98,4 @@ const Account = React.createClass({
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Account);
|
export default connect(makeMapStateToProps)(Account);
|
||||||
|
@@ -1,24 +1,16 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { getAccountTimeline } from '../../selectors';
|
|
||||||
import {
|
import {
|
||||||
fetchAccountTimeline,
|
fetchAccountTimeline,
|
||||||
expandAccountTimeline
|
expandAccountTimeline
|
||||||
} from '../../actions/accounts';
|
} from '../../actions/accounts';
|
||||||
import { deleteStatus } from '../../actions/statuses';
|
|
||||||
import { replyCompose } from '../../actions/compose';
|
|
||||||
import {
|
|
||||||
favourite,
|
|
||||||
reblog,
|
|
||||||
unreblog,
|
|
||||||
unfavourite
|
|
||||||
} from '../../actions/interactions';
|
|
||||||
import StatusList from '../../components/status_list';
|
import StatusList from '../../components/status_list';
|
||||||
|
import LoadingIndicator from '../../components/loading_indicator';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
statuses: getAccountTimeline(state, Number(props.params.accountId)),
|
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
|
||||||
me: state.getIn(['timelines', 'me'])
|
me: state.getIn(['meta', 'me'])
|
||||||
});
|
});
|
||||||
|
|
||||||
const AccountTimeline = React.createClass({
|
const AccountTimeline = React.createClass({
|
||||||
@@ -26,7 +18,7 @@ const AccountTimeline = React.createClass({
|
|||||||
propTypes: {
|
propTypes: {
|
||||||
params: React.PropTypes.object.isRequired,
|
params: React.PropTypes.object.isRequired,
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
statuses: ImmutablePropTypes.list
|
statusIds: ImmutablePropTypes.list
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -41,38 +33,18 @@ const AccountTimeline = React.createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleReply (status) {
|
|
||||||
this.props.dispatch(replyCompose(status));
|
|
||||||
},
|
|
||||||
|
|
||||||
handleReblog (status) {
|
|
||||||
if (status.get('reblogged')) {
|
|
||||||
this.props.dispatch(unreblog(status));
|
|
||||||
} else {
|
|
||||||
this.props.dispatch(reblog(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleFavourite (status) {
|
|
||||||
if (status.get('favourited')) {
|
|
||||||
this.props.dispatch(unfavourite(status));
|
|
||||||
} else {
|
|
||||||
this.props.dispatch(favourite(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleDelete (status) {
|
|
||||||
this.props.dispatch(deleteStatus(status.get('id')));
|
|
||||||
},
|
|
||||||
|
|
||||||
handleScrollToBottom () {
|
handleScrollToBottom () {
|
||||||
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
|
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { statuses, me } = this.props;
|
const { statusIds, me } = this.props;
|
||||||
|
|
||||||
return <StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} onDelete={this.handleDelete} />
|
if (!statusIds) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,12 +1,8 @@
|
|||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Avatar from '../../../components/avatar';
|
import AccountContainer from '../../followers/containers/account_container';
|
||||||
import DisplayName from '../../../components/display_name';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
const outerStyle = {
|
const outerStyle = {
|
||||||
marginBottom: '10px',
|
|
||||||
borderTop: '1px solid #616b86',
|
|
||||||
position: 'relative'
|
position: 'relative'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,32 +13,12 @@ const headerStyle = {
|
|||||||
padding: '10px',
|
padding: '10px',
|
||||||
color: '#9baec8',
|
color: '#9baec8',
|
||||||
background: '#454b5e',
|
background: '#454b5e',
|
||||||
width: '120px',
|
overflow: 'hidden'
|
||||||
marginTop: '-18px'
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemStyle = {
|
|
||||||
display: 'block',
|
|
||||||
padding: '10px',
|
|
||||||
color: '#9baec8',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textDecoration: 'none'
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayNameStyle = {
|
|
||||||
display: 'block',
|
|
||||||
fontWeight: '500',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis'
|
|
||||||
};
|
|
||||||
|
|
||||||
const acctStyle = {
|
|
||||||
display: 'block',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStyle = {
|
const nextStyle = {
|
||||||
|
display: 'inline-block',
|
||||||
|
float: 'right',
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
color: '#2b90d9'
|
color: '#2b90d9'
|
||||||
};
|
};
|
||||||
@@ -50,7 +26,7 @@ const nextStyle = {
|
|||||||
const SuggestionsBox = React.createClass({
|
const SuggestionsBox = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
accounts: ImmutablePropTypes.list.isRequired,
|
accountIds: ImmutablePropTypes.list,
|
||||||
perWindow: React.PropTypes.number
|
perWindow: React.PropTypes.number
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -73,7 +49,7 @@ const SuggestionsBox = React.createClass({
|
|||||||
|
|
||||||
let newIndex = this.state.index + 1;
|
let newIndex = this.state.index + 1;
|
||||||
|
|
||||||
if (this.props.accounts.skip(this.props.perWindow * newIndex).size === 0) {
|
if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) {
|
||||||
newIndex = 0;
|
newIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,16 +57,16 @@ const SuggestionsBox = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { accounts, perWindow } = this.props;
|
const { accountIds, perWindow } = this.props;
|
||||||
|
|
||||||
if (accounts.size === 0) {
|
if (!accountIds || accountIds.size === 0) {
|
||||||
return <div />;
|
return <div />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextLink = '';
|
let nextLink = '';
|
||||||
|
|
||||||
if (accounts.size > perWindow) {
|
if (accountIds.size > perWindow) {
|
||||||
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Next</a>;
|
nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}>Refresh</a>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,21 +75,7 @@ const SuggestionsBox = React.createClass({
|
|||||||
Who to follow {nextLink}
|
Who to follow {nextLink}
|
||||||
</strong>
|
</strong>
|
||||||
|
|
||||||
{accounts.skip(perWindow * this.state.index).take(perWindow).map(account => {
|
{accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)}
|
||||||
let displayName = account.get('display_name');
|
|
||||||
|
|
||||||
if (displayName.length === 0) {
|
|
||||||
displayName = account.get('username');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={account.get('id')} style={itemStyle} to={`/accounts/${account.get('id')}`}>
|
|
||||||
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
|
|
||||||
<strong style={displayNameStyle}>{displayName}</strong>
|
|
||||||
<span style={acctStyle}>{account.get('acct')}</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,8 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { getSuggestions } from '../../../selectors';
|
|
||||||
import SuggestionsBox from '../components/suggestions_box';
|
import SuggestionsBox from '../components/suggestions_box';
|
||||||
|
|
||||||
const mapStateToProps = (state) => ({
|
const mapStateToProps = (state) => ({
|
||||||
accounts: getSuggestions(state)
|
accountIds: state.getIn(['user_lists', 'suggestions'])
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps)(SuggestionsBox);
|
export default connect(mapStateToProps)(SuggestionsBox);
|
||||||
|
@@ -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);
|
@@ -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;
|
@@ -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);
|
@@ -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);
|
@@ -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);
|
@@ -6,7 +6,6 @@ const GettingStarted = () => {
|
|||||||
<Column>
|
<Column>
|
||||||
<div className='static-content'>
|
<div className='static-content'>
|
||||||
<h1>Getting started</h1>
|
<h1>Getting started</h1>
|
||||||
<p>Mastodon is still in development and one of the lacking areas at the moment is user discovery.</p>
|
|
||||||
<p>You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.</p>
|
<p>You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.</p>
|
||||||
<p>If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.</p>
|
<p>If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.</p>
|
||||||
<p>The developer of this project can be followed as Gargron@mastodon.social</p>
|
<p>The developer of this project can be followed as Gargron@mastodon.social</p>
|
||||||
|
@@ -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);
|
@@ -19,7 +19,7 @@ const HomeTimeline = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<Column icon='home' heading='Home'>
|
<Column icon='home' heading='Home'>
|
||||||
<StatusListContainer type='home' />
|
<StatusListContainer {...this.props} type='home' />
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@@ -19,7 +19,7 @@ const MentionsTimeline = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<Column icon='at' heading='Mentions'>
|
<Column icon='at' heading='Mentions'>
|
||||||
<StatusListContainer type='mentions' />
|
<StatusListContainer {...this.props} type='mentions' />
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@@ -4,7 +4,8 @@ import StatusListContainer from '../ui/containers/status_list_container';
|
|||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
import {
|
import {
|
||||||
refreshTimeline,
|
refreshTimeline,
|
||||||
updateTimeline
|
updateTimeline,
|
||||||
|
deleteFromTimelines
|
||||||
} from '../../actions/timelines';
|
} from '../../actions/timelines';
|
||||||
|
|
||||||
const PublicTimeline = React.createClass({
|
const PublicTimeline = React.createClass({
|
||||||
@@ -24,7 +25,12 @@ const PublicTimeline = React.createClass({
|
|||||||
this.subscription = App.cable.subscriptions.create('PublicChannel', {
|
this.subscription = App.cable.subscriptions.create('PublicChannel', {
|
||||||
|
|
||||||
received (data) {
|
received (data) {
|
||||||
dispatch(updateTimeline('public', JSON.parse(data.message)));
|
switch(data.type) {
|
||||||
|
case 'update':
|
||||||
|
return dispatch(updateTimeline('public', JSON.parse(data.message)));
|
||||||
|
case 'delete':
|
||||||
|
return dispatch(deleteFromTimelines(data.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
61
app/assets/javascripts/components/features/reblogs/index.jsx
Normal file
61
app/assets/javascripts/components/features/reblogs/index.jsx
Normal 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);
|
@@ -11,6 +11,7 @@ const ActionBar = React.createClass({
|
|||||||
onReblog: React.PropTypes.func.isRequired,
|
onReblog: React.PropTypes.func.isRequired,
|
||||||
onFavourite: React.PropTypes.func.isRequired,
|
onFavourite: React.PropTypes.func.isRequired,
|
||||||
onDelete: React.PropTypes.func.isRequired,
|
onDelete: React.PropTypes.func.isRequired,
|
||||||
|
onMention: React.PropTypes.func.isRequired,
|
||||||
me: React.PropTypes.number.isRequired
|
me: React.PropTypes.number.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ const ActionBar = React.createClass({
|
|||||||
|
|
||||||
if (me === status.getIn(['account', 'id'])) {
|
if (me === status.getIn(['account', 'id'])) {
|
||||||
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
|
menu.push({ text: 'Delete', action: () => this.props.onDelete(status) });
|
||||||
|
} else {
|
||||||
|
menu.push({ text: 'Mention', action: () => this.props.onMention(status.get('account')) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -6,6 +6,7 @@ import StatusContent from '../../../components/status_content';
|
|||||||
import MediaGallery from '../../../components/media_gallery';
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
import VideoPlayer from '../../../components/video_player';
|
import VideoPlayer from '../../../components/video_player';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
const DetailedStatus = React.createClass({
|
const DetailedStatus = React.createClass({
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ const DetailedStatus = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
status: ImmutablePropTypes.map.isRequired
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
|
onOpenMedia: React.PropTypes.func.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -36,7 +38,7 @@ const DetailedStatus = React.createClass({
|
|||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
|
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
|
||||||
} else {
|
} else {
|
||||||
media = <MediaGallery media={status.get('media_attachments')} height={300} />;
|
media = <MediaGallery media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ const DetailedStatus = React.createClass({
|
|||||||
{media}
|
{media}
|
||||||
|
|
||||||
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
|
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
|
||||||
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span> · <i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span>
|
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span></Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -9,29 +9,42 @@ import DetailedStatus from './components/detailed_status';
|
|||||||
import ActionBar from './components/action_bar';
|
import ActionBar from './components/action_bar';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
import { favourite, reblog } from '../../actions/interactions';
|
import { favourite, reblog } from '../../actions/interactions';
|
||||||
import { replyCompose } from '../../actions/compose';
|
import {
|
||||||
|
replyCompose,
|
||||||
|
mentionCompose
|
||||||
|
} from '../../actions/compose';
|
||||||
import { deleteStatus } from '../../actions/statuses';
|
import { deleteStatus } from '../../actions/statuses';
|
||||||
import {
|
import {
|
||||||
getStatus,
|
makeGetStatus,
|
||||||
getStatusAncestors,
|
getStatusAncestors,
|
||||||
getStatusDescendants
|
getStatusDescendants
|
||||||
} from '../../selectors';
|
} from '../../selectors';
|
||||||
|
import { ScrollContainer } from 'react-router-scroll';
|
||||||
|
import ColumnBackButton from '../../components/column_back_button';
|
||||||
|
import StatusContainer from '../../containers/status_container';
|
||||||
|
import { openMedia } from '../../actions/modal';
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
status: getStatus(state, Number(props.params.statusId)),
|
status: getStatus(state, Number(props.params.statusId)),
|
||||||
ancestors: getStatusAncestors(state, Number(props.params.statusId)),
|
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
|
||||||
descendants: getStatusDescendants(state, Number(props.params.statusId)),
|
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
|
||||||
me: state.getIn(['timelines', 'me'])
|
me: state.getIn(['meta', 'me'])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
const Status = React.createClass({
|
const Status = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
params: React.PropTypes.object.isRequired,
|
params: React.PropTypes.object.isRequired,
|
||||||
dispatch: React.PropTypes.func.isRequired,
|
dispatch: React.PropTypes.func.isRequired,
|
||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
ancestors: ImmutablePropTypes.orderedSet.isRequired,
|
ancestorsIds: ImmutablePropTypes.list,
|
||||||
descendants: ImmutablePropTypes.orderedSet.isRequired
|
descendantsIds: ImmutablePropTypes.list
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -62,12 +75,21 @@ const Status = React.createClass({
|
|||||||
this.props.dispatch(deleteStatus(status.get('id')));
|
this.props.dispatch(deleteStatus(status.get('id')));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleMentionClick (account) {
|
||||||
|
this.props.dispatch(mentionCompose(account));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOpenMedia (url) {
|
||||||
|
this.props.dispatch(openMedia(url));
|
||||||
|
},
|
||||||
|
|
||||||
renderChildren (list) {
|
renderChildren (list) {
|
||||||
return list.map(s => <EmbeddedStatus status={s} me={this.props.me} key={s.get('id')} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />);
|
return list.map(id => <StatusContainer key={id} id={id} />);
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, ancestors, descendants, me } = this.props;
|
let ancestors, descendants;
|
||||||
|
const { status, ancestorsIds, descendantsIds, me } = this.props;
|
||||||
|
|
||||||
if (status === null) {
|
if (status === null) {
|
||||||
return (
|
return (
|
||||||
@@ -79,20 +101,32 @@ const Status = React.createClass({
|
|||||||
|
|
||||||
const account = status.get('account');
|
const account = status.get('account');
|
||||||
|
|
||||||
|
if (ancestorsIds && ancestorsIds.size > 0) {
|
||||||
|
ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descendantsIds && descendantsIds.size > 0) {
|
||||||
|
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
<ColumnBackButton />
|
||||||
<div>{this.renderChildren(ancestors)}</div>
|
|
||||||
|
|
||||||
<DetailedStatus status={status} me={me} />
|
<ScrollContainer scrollKey='thread'>
|
||||||
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} />
|
<div className='scrollable'>
|
||||||
|
{ancestors}
|
||||||
|
|
||||||
<div>{this.renderChildren(descendants)}</div>
|
<DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} />
|
||||||
|
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} />
|
||||||
|
|
||||||
|
{descendants}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollContainer>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Status);
|
export default connect(makeMapStateToProps)(Status);
|
||||||
|
@@ -4,11 +4,62 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ReplyIndicator from './reply_indicator';
|
import ReplyIndicator from './reply_indicator';
|
||||||
import UploadButton from './upload_button';
|
import UploadButton from './upload_button';
|
||||||
|
import Autosuggest from 'react-autosuggest';
|
||||||
|
|
||||||
|
const getTokenForSuggestions = (str, caretPosition) => {
|
||||||
|
let word;
|
||||||
|
|
||||||
|
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||||
|
let right = str.slice(caretPosition).search(/\s/);
|
||||||
|
|
||||||
|
if (right < 0) {
|
||||||
|
word = str.slice(left);
|
||||||
|
} else {
|
||||||
|
word = str.slice(left, right + caretPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!word || word.trim().length < 2 || word[0] !== '@') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
word = word.trim().toLowerCase().slice(1);
|
||||||
|
|
||||||
|
if (word.length > 0) {
|
||||||
|
return word;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSuggestionValue = suggestion => suggestion.completion;
|
||||||
|
|
||||||
|
const renderSuggestion = suggestion => (
|
||||||
|
<span>{suggestion.label}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const textareaStyle = {
|
||||||
|
display: 'block',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: '100%',
|
||||||
|
height: '100px',
|
||||||
|
resize: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#282c37',
|
||||||
|
padding: '10px',
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
fontSize: '14px',
|
||||||
|
margin: '0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInputComponent = inputProps => (
|
||||||
|
<textarea {...inputProps} placeholder='What is on your mind?' className='compose-form__textarea' style={textareaStyle} />
|
||||||
|
);
|
||||||
|
|
||||||
const ComposeForm = React.createClass({
|
const ComposeForm = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
text: React.PropTypes.string.isRequired,
|
text: React.PropTypes.string.isRequired,
|
||||||
|
suggestions: React.PropTypes.array,
|
||||||
is_submitting: React.PropTypes.bool,
|
is_submitting: React.PropTypes.bool,
|
||||||
is_uploading: React.PropTypes.bool,
|
is_uploading: React.PropTypes.bool,
|
||||||
in_reply_to: ImmutablePropTypes.map,
|
in_reply_to: ImmutablePropTypes.map,
|
||||||
@@ -35,7 +86,39 @@ const ComposeForm = React.createClass({
|
|||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
componentDidUpdate (prevProps) {
|
||||||
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
|
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
|
||||||
this.refs.textarea.focus();
|
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
|
||||||
|
const textarea = node.querySelector('textarea');
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuggestionsClearRequested () {
|
||||||
|
this.props.onClearSuggestions();
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuggestionsFetchRequested ({ value }) {
|
||||||
|
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
|
||||||
|
const textarea = node.querySelector('textarea');
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
const token = getTokenForSuggestions(value, textarea.selectionStart);
|
||||||
|
|
||||||
|
if (token !== null) {
|
||||||
|
this.props.onFetchSuggestions(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuggestionSelected (e, { suggestionValue, method }) {
|
||||||
|
const node = ReactDOM.findDOMNode(this.refs.autosuggest);
|
||||||
|
const textarea = node.querySelector('textarea');
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
const str = this.props.text;
|
||||||
|
this.props.onChange([str.slice(0, textarea.selectionStart), suggestionValue, str.slice(textarea.selectionStart)].join(''));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -47,11 +130,29 @@ const ComposeForm = React.createClass({
|
|||||||
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputProps = {
|
||||||
|
placeholder: 'What is on your mind?',
|
||||||
|
value: this.props.text,
|
||||||
|
onKeyUp: this.handleKeyUp,
|
||||||
|
onChange: this.handleChange,
|
||||||
|
disabled: disabled
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '10px' }}>
|
<div style={{ padding: '10px' }}>
|
||||||
{replyArea}
|
{replyArea}
|
||||||
|
|
||||||
<textarea ref='textarea' disabled={disabled} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} />
|
<Autosuggest
|
||||||
|
ref='autosuggest'
|
||||||
|
suggestions={this.props.suggestions}
|
||||||
|
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||||
|
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||||
|
onSuggestionSelected={this.onSuggestionSelected}
|
||||||
|
getSuggestionValue={getSuggestionValue}
|
||||||
|
renderSuggestion={renderSuggestion}
|
||||||
|
renderInputComponent={renderInputComponent}
|
||||||
|
inputProps={inputProps}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||||
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>
|
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>
|
||||||
|
@@ -24,7 +24,7 @@ const UploadButton = React.createClass({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
|
<Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
|
||||||
<i className='fa fa-fw fa-photo' /> Add images
|
<i className='fa fa-fw fa-photo' /> Add media
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
|
<input ref='fileElement' type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
|
||||||
|
@@ -1,31 +1,52 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import ComposeForm from '../components/compose_form';
|
import ComposeForm from '../components/compose_form';
|
||||||
import { changeCompose, submitCompose, cancelReplyCompose } from '../../../actions/compose';
|
import {
|
||||||
import { getStatus } from '../../../selectors';
|
changeCompose,
|
||||||
|
submitCompose,
|
||||||
|
cancelReplyCompose,
|
||||||
|
clearComposeSuggestions,
|
||||||
|
fetchComposeSuggestions
|
||||||
|
} from '../../../actions/compose';
|
||||||
|
import { makeGetStatus } from '../../../selectors';
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
const mapStateToProps = function (state, props) {
|
const mapStateToProps = function (state, props) {
|
||||||
return {
|
return {
|
||||||
text: state.getIn(['compose', 'text']),
|
text: state.getIn(['compose', 'text']),
|
||||||
|
suggestions: state.getIn(['compose', 'suggestions']),
|
||||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||||
is_uploading: state.getIn(['compose', 'is_uploading']),
|
is_uploading: state.getIn(['compose', 'is_uploading']),
|
||||||
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
|
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = function (dispatch) {
|
const mapDispatchToProps = function (dispatch) {
|
||||||
return {
|
return {
|
||||||
onChange: function (text) {
|
onChange (text) {
|
||||||
dispatch(changeCompose(text));
|
dispatch(changeCompose(text));
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit: function () {
|
onSubmit () {
|
||||||
dispatch(submitCompose());
|
dispatch(submitCompose());
|
||||||
},
|
},
|
||||||
|
|
||||||
onCancelReply: function () {
|
onCancelReply () {
|
||||||
dispatch(cancelReplyCompose());
|
dispatch(cancelReplyCompose());
|
||||||
|
},
|
||||||
|
|
||||||
|
onClearSuggestions () {
|
||||||
|
dispatch(clearComposeSuggestions());
|
||||||
|
},
|
||||||
|
|
||||||
|
onFetchSuggestions (token) {
|
||||||
|
dispatch(fetchComposeSuggestions(token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
|
export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);
|
||||||
|
@@ -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);
|
@@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
|||||||
import NavigationBar from '../components/navigation_bar';
|
import NavigationBar from '../components/navigation_bar';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
account: state.getIn(['timelines', 'accounts', state.getIn(['timelines', 'me'])])
|
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps)(NavigationBar);
|
export default connect(mapStateToProps)(NavigationBar);
|
||||||
|
@@ -1,57 +1,18 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import StatusList from '../../../components/status_list';
|
import StatusList from '../../../components/status_list';
|
||||||
import { replyCompose } from '../../../actions/compose';
|
|
||||||
import {
|
|
||||||
reblog,
|
|
||||||
favourite,
|
|
||||||
unreblog,
|
|
||||||
unfavourite
|
|
||||||
} from '../../../actions/interactions';
|
|
||||||
import { expandTimeline } from '../../../actions/timelines';
|
import { expandTimeline } from '../../../actions/timelines';
|
||||||
import { makeGetTimeline } from '../../../selectors';
|
import Immutable from 'immutable';
|
||||||
import { deleteStatus } from '../../../actions/statuses';
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getTimeline = makeGetTimeline();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
statuses: getTimeline(state, props.type),
|
statusIds: state.getIn(['timelines', props.type], Immutable.List())
|
||||||
me: state.getIn(['timelines', 'me'])
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = function (dispatch, props) {
|
const mapDispatchToProps = function (dispatch, props) {
|
||||||
return {
|
return {
|
||||||
onReply (status) {
|
|
||||||
dispatch(replyCompose(status));
|
|
||||||
},
|
|
||||||
|
|
||||||
onFavourite (status) {
|
|
||||||
if (status.get('favourited')) {
|
|
||||||
dispatch(unfavourite(status));
|
|
||||||
} else {
|
|
||||||
dispatch(favourite(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onReblog (status) {
|
|
||||||
if (status.get('reblogged')) {
|
|
||||||
dispatch(unreblog(status));
|
|
||||||
} else {
|
|
||||||
dispatch(reblog(status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onScrollToBottom () {
|
onScrollToBottom () {
|
||||||
dispatch(expandTimeline(props.type));
|
dispatch(expandTimeline(props.type, props.id));
|
||||||
},
|
|
||||||
|
|
||||||
onDelete (status) {
|
|
||||||
dispatch(deleteStatus(status.get('id')));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
|
export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
|
||||||
|
@@ -7,6 +7,7 @@ import MentionsTimeline from '../mentions_timeline';
|
|||||||
import Compose from '../compose';
|
import Compose from '../compose';
|
||||||
import MediaQuery from 'react-responsive';
|
import MediaQuery from 'react-responsive';
|
||||||
import TabsBar from './components/tabs_bar';
|
import TabsBar from './components/tabs_bar';
|
||||||
|
import ModalContainer from './containers/modal_container';
|
||||||
|
|
||||||
const UI = React.createClass({
|
const UI = React.createClass({
|
||||||
|
|
||||||
@@ -28,14 +29,15 @@ const UI = React.createClass({
|
|||||||
<MediaQuery minWidth={layoutBreakpoint}>
|
<MediaQuery minWidth={layoutBreakpoint}>
|
||||||
<ColumnsArea>
|
<ColumnsArea>
|
||||||
<Compose />
|
<Compose />
|
||||||
<HomeTimeline />
|
<HomeTimeline trackScroll={false} />
|
||||||
<MentionsTimeline />
|
<MentionsTimeline trackScroll={false} />
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</ColumnsArea>
|
</ColumnsArea>
|
||||||
</MediaQuery>
|
</MediaQuery>
|
||||||
|
|
||||||
<NotificationsContainer />
|
<NotificationsContainer />
|
||||||
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
|
<LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
|
||||||
|
<ModalContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
89
app/assets/javascripts/components/reducers/accounts.jsx
Normal file
89
app/assets/javascripts/components/reducers/accounts.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@@ -2,6 +2,7 @@ import {
|
|||||||
COMPOSE_CHANGE,
|
COMPOSE_CHANGE,
|
||||||
COMPOSE_REPLY,
|
COMPOSE_REPLY,
|
||||||
COMPOSE_REPLY_CANCEL,
|
COMPOSE_REPLY_CANCEL,
|
||||||
|
COMPOSE_MENTION,
|
||||||
COMPOSE_SUBMIT_REQUEST,
|
COMPOSE_SUBMIT_REQUEST,
|
||||||
COMPOSE_SUBMIT_SUCCESS,
|
COMPOSE_SUBMIT_SUCCESS,
|
||||||
COMPOSE_SUBMIT_FAIL,
|
COMPOSE_SUBMIT_FAIL,
|
||||||
@@ -9,7 +10,9 @@ import {
|
|||||||
COMPOSE_UPLOAD_SUCCESS,
|
COMPOSE_UPLOAD_SUCCESS,
|
||||||
COMPOSE_UPLOAD_FAIL,
|
COMPOSE_UPLOAD_FAIL,
|
||||||
COMPOSE_UPLOAD_UNDO,
|
COMPOSE_UPLOAD_UNDO,
|
||||||
COMPOSE_UPLOAD_PROGRESS
|
COMPOSE_UPLOAD_PROGRESS,
|
||||||
|
COMPOSE_SUGGESTIONS_CLEAR,
|
||||||
|
COMPOSE_SUGGESTIONS_READY
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
||||||
@@ -21,7 +24,8 @@ const initialState = Immutable.Map({
|
|||||||
is_submitting: false,
|
is_submitting: false,
|
||||||
is_uploading: false,
|
is_uploading: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
media_attachments: Immutable.List([]),
|
media_attachments: Immutable.List(),
|
||||||
|
suggestions: [],
|
||||||
me: null
|
me: null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +96,12 @@ export default function compose(state = initialState, action) {
|
|||||||
return removeMedia(state, action.media_id);
|
return removeMedia(state, action.media_id);
|
||||||
case COMPOSE_UPLOAD_PROGRESS:
|
case COMPOSE_UPLOAD_PROGRESS:
|
||||||
return state.set('progress', Math.round((action.loaded / action.total) * 100));
|
return state.set('progress', Math.round((action.loaded / action.total) * 100));
|
||||||
|
case COMPOSE_MENTION:
|
||||||
|
return state.update('text', text => `${text}@${action.account.get('acct')} `);
|
||||||
|
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||||
|
return state.set('suggestions', []);
|
||||||
|
case COMPOSE_SUGGESTIONS_READY:
|
||||||
|
return state.set('suggestions', action.accounts);
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
if (action.id === state.get('in_reply_to')) {
|
if (action.id === state.get('in_reply_to')) {
|
||||||
return state.set('in_reply_to', null);
|
return state.set('in_reply_to', null);
|
||||||
|
@@ -5,6 +5,11 @@ import compose from './compose';
|
|||||||
import follow from './follow';
|
import follow from './follow';
|
||||||
import notifications from './notifications';
|
import notifications from './notifications';
|
||||||
import { loadingBarReducer } from 'react-redux-loading-bar';
|
import { loadingBarReducer } from 'react-redux-loading-bar';
|
||||||
|
import modal from './modal';
|
||||||
|
import user_lists from './user_lists';
|
||||||
|
import accounts from './accounts';
|
||||||
|
import statuses from './statuses';
|
||||||
|
import relationships from './relationships';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
timelines,
|
timelines,
|
||||||
@@ -13,4 +18,9 @@ export default combineReducers({
|
|||||||
follow,
|
follow,
|
||||||
notifications,
|
notifications,
|
||||||
loadingBar: loadingBarReducer,
|
loadingBar: loadingBarReducer,
|
||||||
|
modal,
|
||||||
|
user_lists,
|
||||||
|
accounts,
|
||||||
|
statuses,
|
||||||
|
relationships
|
||||||
});
|
});
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { ACCESS_TOKEN_SET } from '../actions/meta';
|
import { ACCESS_TOKEN_SET } from '../actions/meta';
|
||||||
|
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map();
|
const initialState = Immutable.Map();
|
||||||
@@ -7,6 +8,8 @@ export default function meta(state = initialState, action) {
|
|||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case ACCESS_TOKEN_SET:
|
case ACCESS_TOKEN_SET:
|
||||||
return state.set('access_token', action.token);
|
return state.set('access_token', action.token);
|
||||||
|
case ACCOUNT_SET_SELF:
|
||||||
|
return state.set('me', action.account.id);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
21
app/assets/javascripts/components/reducers/modal.jsx
Normal file
21
app/assets/javascripts/components/reducers/modal.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
34
app/assets/javascripts/components/reducers/relationships.jsx
Normal file
34
app/assets/javascripts/components/reducers/relationships.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
73
app/assets/javascripts/components/reducers/statuses.jsx
Normal file
73
app/assets/javascripts/components/reducers/statuses.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@@ -11,108 +11,77 @@ import {
|
|||||||
UNFAVOURITE_SUCCESS
|
UNFAVOURITE_SUCCESS
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_SET_SELF,
|
|
||||||
ACCOUNT_FETCH_SUCCESS,
|
ACCOUNT_FETCH_SUCCESS,
|
||||||
ACCOUNT_FOLLOW_SUCCESS,
|
|
||||||
ACCOUNT_UNFOLLOW_SUCCESS,
|
|
||||||
ACCOUNT_BLOCK_SUCCESS,
|
|
||||||
ACCOUNT_UNBLOCK_SUCCESS,
|
|
||||||
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS
|
ACCOUNT_TIMELINE_EXPAND_SUCCESS
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import {
|
import {
|
||||||
STATUS_FETCH_SUCCESS,
|
STATUS_FETCH_SUCCESS,
|
||||||
STATUS_DELETE_SUCCESS
|
CONTEXT_FETCH_SUCCESS
|
||||||
} from '../actions/statuses';
|
} from '../actions/statuses';
|
||||||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
|
||||||
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
|
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = Immutable.Map({
|
||||||
home: Immutable.List([]),
|
home: Immutable.List(),
|
||||||
mentions: Immutable.List([]),
|
mentions: Immutable.List(),
|
||||||
public: Immutable.List([]),
|
public: Immutable.List(),
|
||||||
statuses: Immutable.Map(),
|
tag: Immutable.List(),
|
||||||
accounts: Immutable.Map(),
|
|
||||||
accounts_timelines: Immutable.Map(),
|
accounts_timelines: Immutable.Map(),
|
||||||
me: null,
|
|
||||||
ancestors: Immutable.Map(),
|
ancestors: Immutable.Map(),
|
||||||
descendants: Immutable.Map(),
|
descendants: Immutable.Map()
|
||||||
relationships: Immutable.Map(),
|
|
||||||
suggestions: Immutable.List([])
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeStatus(state, status) {
|
const normalizeStatus = (state, status) => {
|
||||||
// Separate account
|
const replyToId = status.get('in_reply_to_id');
|
||||||
let account = status.get('account');
|
const id = status.get('id');
|
||||||
status = status.set('account', account.get('id'));
|
|
||||||
|
|
||||||
// Separate reblog, repeat for reblog
|
if (replyToId) {
|
||||||
let reblog = status.get('reblog', null);
|
if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
|
||||||
|
state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
|
||||||
if (reblog !== null) {
|
|
||||||
status = status.set('reblog', reblog.get('id'));
|
|
||||||
state = normalizeStatus(state, reblog);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replies
|
if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
|
||||||
if (status.get('in_reply_to_id')) {
|
state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
|
||||||
state = state.updateIn(['descendants', status.get('in_reply_to_id')], set => {
|
|
||||||
if (!Immutable.OrderedSet.isOrderedSet(set)) {
|
|
||||||
return Immutable.OrderedSet([status.get('id')]);
|
|
||||||
} else {
|
|
||||||
return set.add(status.get('id'));
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.withMutations(map => {
|
return state;
|
||||||
if (status.get('in_reply_to_id')) {
|
|
||||||
map.updateIn(['descendants', status.get('in_reply_to_id')], Immutable.OrderedSet(), set => set.add(status.get('id')));
|
|
||||||
map.updateIn(['ancestors', status.get('id')], Immutable.OrderedSet(), set => set.add(status.get('in_reply_to_id')));
|
|
||||||
}
|
|
||||||
|
|
||||||
map.setIn(['accounts', account.get('id')], account);
|
|
||||||
map.setIn(['statuses', status.get('id')], status);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeTimeline(state, timeline, statuses) {
|
const normalizeTimeline = (state, timeline, statuses, replace = false) => {
|
||||||
let ids = Immutable.List([]);
|
let ids = Immutable.List();
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
ids = ids.set(i, status.get('id'));
|
ids = ids.set(i, status.get('id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
return state.set(timeline, ids);
|
return state.update(timeline, Immutable.List(), list => (replace ? ids : list.unshift(...ids)));
|
||||||
};
|
};
|
||||||
|
|
||||||
function appendNormalizedTimeline(state, timeline, statuses) {
|
const appendNormalizedTimeline = (state, timeline, statuses) => {
|
||||||
let moreIds = Immutable.List([]);
|
let moreIds = Immutable.List();
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
moreIds = moreIds.set(i, status.get('id'));
|
moreIds = moreIds.set(i, status.get('id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
return state.update(timeline, list => list.push(...moreIds));
|
return state.update(timeline, Immutable.List(), list => list.push(...moreIds));
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeAccountTimeline(state, accountId, statuses) {
|
const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
|
||||||
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => {
|
let ids = Immutable.List();
|
||||||
return (list.size > 0) ? list.clear() : list;
|
|
||||||
});
|
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.set(i, status.get('id')));
|
ids = ids.set(i, status.get('id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
return state;
|
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
|
||||||
};
|
};
|
||||||
|
|
||||||
function appendNormalizedAccountTimeline(state, accountId, statuses) {
|
const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
|
||||||
let moreIds = Immutable.List([]);
|
let moreIds = Immutable.List([]);
|
||||||
|
|
||||||
statuses.forEach((status, i) => {
|
statuses.forEach((status, i) => {
|
||||||
@@ -123,133 +92,80 @@ function appendNormalizedAccountTimeline(state, accountId, statuses) {
|
|||||||
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
|
return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateTimeline(state, timeline, status) {
|
const updateTimeline = (state, timeline, status, references) => {
|
||||||
state = normalizeStatus(state, status);
|
state = normalizeStatus(state, status);
|
||||||
|
|
||||||
state = state.update(timeline, list => {
|
state = state.update(timeline, Immutable.List(), list => {
|
||||||
|
if (list.includes(status.get('id'))) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
const reblogOfId = status.getIn(['reblog', 'id'], null);
|
const reblogOfId = status.getIn(['reblog', 'id'], null);
|
||||||
|
|
||||||
if (reblogOfId !== null) {
|
if (reblogOfId !== null) {
|
||||||
const otherReblogs = state.get('statuses').filter(item => item.get('reblog') === reblogOfId).map((_, itemId) => itemId);
|
list = list.filterNot(itemId => references.includes(itemId));
|
||||||
list = list.filterNot(itemId => (itemId === reblogOfId || otherReblogs.includes(itemId)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.unshift(status.get('id'));
|
return list.unshift(status.get('id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
state = state.updateIn(['accounts_timelines', status.getIn(['account', 'id'])], Immutable.List([]), list => (list.includes(status.get('id')) ? list : list.unshift(status.get('id'))));
|
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
function deleteStatus(state, id) {
|
const deleteStatus = (state, id, accountId, references) => {
|
||||||
const status = state.getIn(['statuses', id]);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove references from timelines
|
// Remove references from timelines
|
||||||
['home', 'mentions'].forEach(function (timeline) {
|
['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
|
||||||
state = state.update(timeline, list => list.filterNot(item => item === id));
|
state = state.update(timeline, list => list.filterNot(item => item === id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove references from account timelines
|
// Remove references from account timelines
|
||||||
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List([]), list => list.filterNot(item => item === id));
|
state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
|
||||||
|
|
||||||
|
// Remove references from context
|
||||||
|
state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
|
||||||
|
state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
|
||||||
|
});
|
||||||
|
|
||||||
|
state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => {
|
||||||
|
state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
|
||||||
|
});
|
||||||
|
|
||||||
|
state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
|
||||||
|
|
||||||
// Remove reblogs of deleted status
|
// Remove reblogs of deleted status
|
||||||
const references = state.get('statuses').filter(item => item.get('reblog') === id);
|
references.forEach(ref => {
|
||||||
|
state = deleteStatus(state, ref[0], ref[1], []);
|
||||||
references.forEach(referencingId => {
|
|
||||||
state = deleteStatus(state, referencingId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove normalized status
|
return state;
|
||||||
return state.deleteIn(['statuses', id]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeAccount(state, account, relationship) {
|
const normalizeContext = (state, id, ancestors, descendants) => {
|
||||||
if (relationship) {
|
const ancestorsIds = ancestors.map(ancestor => ancestor.get('id'));
|
||||||
state = normalizeRelationship(state, relationship);
|
const descendantsIds = descendants.map(descendant => descendant.get('id'));
|
||||||
}
|
|
||||||
|
|
||||||
return state.setIn(['accounts', account.get('id')], account);
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeRelationship(state, relationship) {
|
|
||||||
if (state.get('suggestions').includes(relationship.get('id')) && (relationship.get('following') || relationship.get('blocking'))) {
|
|
||||||
state = state.update('suggestions', list => list.filterNot(id => id === relationship.get('id')));
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.setIn(['relationships', relationship.get('id')], relationship);
|
|
||||||
};
|
|
||||||
|
|
||||||
function setSelf(state, account) {
|
|
||||||
state = normalizeAccount(state, account);
|
|
||||||
return state.set('me', account.get('id'));
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeContext(state, status, ancestors, descendants) {
|
|
||||||
state = normalizeStatus(state, status);
|
|
||||||
|
|
||||||
let ancestorsIds = ancestors.map(ancestor => {
|
|
||||||
state = normalizeStatus(state, ancestor);
|
|
||||||
return ancestor.get('id');
|
|
||||||
}).toOrderedSet();
|
|
||||||
|
|
||||||
let descendantsIds = descendants.map(descendant => {
|
|
||||||
state = normalizeStatus(state, descendant);
|
|
||||||
return descendant.get('id');
|
|
||||||
}).toOrderedSet();
|
|
||||||
|
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.setIn(['ancestors', status.get('id')], ancestorsIds);
|
map.setIn(['ancestors', id], ancestorsIds);
|
||||||
map.setIn(['descendants', status.get('id')], descendantsIds);
|
map.setIn(['descendants', id], descendantsIds);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeSuggestions(state, accounts) {
|
|
||||||
accounts.forEach(account => {
|
|
||||||
state = state.setIn(['accounts', account.get('id')], account);
|
|
||||||
});
|
|
||||||
|
|
||||||
return state.set('suggestions', accounts.map(account => account.get('id')));
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function timelines(state = initialState, action) {
|
export default function timelines(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case TIMELINE_REFRESH_SUCCESS:
|
case TIMELINE_REFRESH_SUCCESS:
|
||||||
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.replace);
|
||||||
case TIMELINE_EXPAND_SUCCESS:
|
case TIMELINE_EXPAND_SUCCESS:
|
||||||
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
|
||||||
case TIMELINE_UPDATE:
|
case TIMELINE_UPDATE:
|
||||||
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status));
|
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
case STATUS_DELETE_SUCCESS:
|
return deleteStatus(state, action.id, action.accountId, action.references);
|
||||||
return deleteStatus(state, action.id);
|
case CONTEXT_FETCH_SUCCESS:
|
||||||
case REBLOG_SUCCESS:
|
return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
|
||||||
case FAVOURITE_SUCCESS:
|
|
||||||
case UNREBLOG_SUCCESS:
|
|
||||||
case UNFAVOURITE_SUCCESS:
|
|
||||||
return normalizeStatus(state, Immutable.fromJS(action.response));
|
|
||||||
case ACCOUNT_SET_SELF:
|
|
||||||
return setSelf(state, Immutable.fromJS(action.account));
|
|
||||||
case ACCOUNT_FETCH_SUCCESS:
|
|
||||||
case FOLLOW_SUBMIT_SUCCESS:
|
|
||||||
return normalizeAccount(state, Immutable.fromJS(action.account), Immutable.fromJS(action.relationship));
|
|
||||||
case ACCOUNT_FOLLOW_SUCCESS:
|
|
||||||
case ACCOUNT_UNFOLLOW_SUCCESS:
|
|
||||||
case ACCOUNT_UNBLOCK_SUCCESS:
|
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
|
||||||
return normalizeRelationship(state, Immutable.fromJS(action.relationship));
|
|
||||||
case STATUS_FETCH_SUCCESS:
|
|
||||||
return normalizeContext(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants));
|
|
||||||
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
case ACCOUNT_TIMELINE_FETCH_SUCCESS:
|
||||||
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
|
||||||
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
|
||||||
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
|
||||||
case SUGGESTIONS_FETCH_SUCCESS:
|
|
||||||
return normalizeSuggestions(state, Immutable.fromJS(action.suggestions));
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
35
app/assets/javascripts/components/reducers/user_lists.jsx
Normal file
35
app/assets/javascripts/components/reducers/user_lists.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@@ -1,31 +1,33 @@
|
|||||||
import { createSelector } from 'reselect'
|
import { createSelector } from 'reselect'
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const getStatuses = state => state.getIn(['timelines', 'statuses']);
|
const getStatuses = state => state.get('statuses');
|
||||||
const getAccounts = state => state.getIn(['timelines', 'accounts']);
|
const getAccounts = state => state.get('accounts');
|
||||||
|
|
||||||
const getAccountBase = (state, id) => state.getIn(['timelines', 'accounts', id], null);
|
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
||||||
const getAccountRelationship = (state, id) => state.getIn(['timelines', 'relationships', id]);
|
const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
|
||||||
|
|
||||||
export const getAccount = createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
|
export const makeGetAccount = () => {
|
||||||
|
return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
|
||||||
if (base === null) {
|
if (base === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return base.set('relationship', relationship);
|
return base.set('relationship', relationship);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusBase = (state, id) => state.getIn(['timelines', 'statuses', id], null);
|
const getStatusBase = (state, id) => state.getIn(['statuses', id], null);
|
||||||
|
|
||||||
export const getStatus = createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
|
export const makeGetStatus = () => {
|
||||||
|
return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
|
||||||
if (base === null) {
|
if (base === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return assembleStatus(base.get('id'), statuses, accounts);
|
return assembleStatus(base.get('id'), statuses, accounts);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
const getAccountTimelineIds = (state, id) => state.getIn(['timelines', 'accounts_timelines', id], Immutable.List());
|
|
||||||
|
|
||||||
const assembleStatus = (id, statuses, accounts) => {
|
const assembleStatus = (id, statuses, accounts) => {
|
||||||
let status = statuses.get(id, null);
|
let status = statuses.get(id, null);
|
||||||
@@ -48,26 +50,6 @@ const assembleStatus = (id, statuses, accounts) => {
|
|||||||
return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
|
return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
|
||||||
};
|
};
|
||||||
|
|
||||||
const assembleStatusList = (ids, statuses, accounts) => {
|
|
||||||
return ids.map(statusId => assembleStatus(statusId, statuses, accounts)).filterNot(status => status === null);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAccountTimeline = createSelector([getAccountTimelineIds, getStatuses, getAccounts], assembleStatusList);
|
|
||||||
|
|
||||||
const getTimelineIds = (state, timelineType) => state.getIn(['timelines', timelineType]);
|
|
||||||
|
|
||||||
export const makeGetTimeline = () => {
|
|
||||||
return createSelector([getTimelineIds, getStatuses, getAccounts], assembleStatusList);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusAncestorsIds = (state, id) => state.getIn(['timelines', 'ancestors', id], Immutable.OrderedSet());
|
|
||||||
|
|
||||||
export const getStatusAncestors = createSelector([getStatusAncestorsIds, getStatuses, getAccounts], assembleStatusList);
|
|
||||||
|
|
||||||
const getStatusDescendantsIds = (state, id) => state.getIn(['timelines', 'descendants', id], Immutable.OrderedSet());
|
|
||||||
|
|
||||||
export const getStatusDescendants = createSelector([getStatusDescendantsIds, getStatuses, getAccounts], assembleStatusList);
|
|
||||||
|
|
||||||
const getNotificationsBase = state => state.get('notifications');
|
const getNotificationsBase = state => state.get('notifications');
|
||||||
|
|
||||||
export const getNotifications = createSelector([getNotificationsBase], (base) => {
|
export const getNotifications = createSelector([getNotificationsBase], (base) => {
|
||||||
@@ -84,9 +66,3 @@ export const getNotifications = createSelector([getNotificationsBase], (base) =>
|
|||||||
|
|
||||||
return arr;
|
return arr;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSuggestionsBase = (state) => state.getIn(['timelines', 'suggestions']);
|
|
||||||
|
|
||||||
export const getSuggestions = createSelector([getSuggestionsBase, getAccounts], (base, accounts) => {
|
|
||||||
return base.map(accountId => accounts.get(accountId));
|
|
||||||
});
|
|
||||||
|
@@ -6,7 +6,8 @@
|
|||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #9baec8;
|
color: #9baec8;
|
||||||
padding-top: 200px;
|
padding-top: 50px;
|
||||||
|
padding-bottom: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -24,10 +25,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
h2 {
|
||||||
|
font: 24px/28px 'Judson', sans-serif;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font: 20px/28px 'Judson', sans-serif;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #d9e1e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
list-style: inherit;
|
||||||
|
margin-left: 20px;
|
||||||
|
|
||||||
|
&[type='a'] {
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[type='i'] {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li > ol, li > ul {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, li {
|
||||||
font: 20px/28px 'Judson', sans-serif;
|
font: 20px/28px 'Judson', sans-serif;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
margin-bottom: 26px;
|
margin-bottom: 26px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #2b90d9;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
em {
|
em {
|
||||||
@@ -39,6 +76,32 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.screenshot {
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
|
||||||
|
margin-bottom: 26px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.info {
|
||||||
|
float: right;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 36px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #9baec8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 360px) {
|
@media screen and (max-width: 360px) {
|
||||||
.wrapper {
|
.wrapper {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
@@ -42,7 +42,8 @@
|
|||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
color: #616b86;
|
color: #616b86;
|
||||||
cursor: pointer;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #717b98;
|
color: #717b98;
|
||||||
@@ -58,6 +59,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lightbox .icon-button {
|
||||||
|
color: #282c37;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-form__textarea, .follow-form__input {
|
.compose-form__textarea, .follow-form__input {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
||||||
@@ -66,6 +71,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emojione {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: inherit;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: -.2ex .15em .2ex;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status__content, .reply-indicator__content {
|
.status__content, .reply-indicator__content {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
@@ -73,6 +89,11 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
.emojione {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
@@ -105,6 +126,11 @@
|
|||||||
.status__content {
|
.status__content {
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
|
|
||||||
|
.emojione {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,17 +143,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime {
|
.account__header__content {
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-weight: 300;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__display-name {
|
.status__display-name, .account__display-name {
|
||||||
strong {
|
strong {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name {
|
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name, .account__display-name {
|
||||||
&:hover {
|
&:hover {
|
||||||
strong {
|
strong {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -135,6 +184,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account__display-name {
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.detailed-status__display-name {
|
.detailed-status__display-name {
|
||||||
color: #d9e1e8;
|
color: #d9e1e8;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
@@ -260,3 +315,38 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-autosuggest__container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-autosuggest__suggestions-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-autosuggest__suggestions-list {
|
||||||
|
background: #9baec8;
|
||||||
|
color: #282c37;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-autosuggest__suggestion {
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-autosuggest__suggestion--focused {
|
||||||
|
background: #2b90d9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
@@ -1,3 +1,8 @@
|
|||||||
|
code {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -47,9 +52,11 @@
|
|||||||
|
|
||||||
input[type=text], input[type=email], input[type=password], textarea {
|
input[type=text], input[type=email], input[type=password], textarea {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 2px solid #9baec8;
|
border-bottom: 2px solid #9baec8;
|
||||||
padding: 7px 0;
|
border-radius: 2px 2px 0 0;
|
||||||
|
padding: 7px 4px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -71,6 +78,7 @@
|
|||||||
|
|
||||||
&:active, &:focus {
|
&:active, &:focus {
|
||||||
border-bottom-color: #2b90d9;
|
border-bottom-color: #2b90d9;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,31 +93,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #9baec8;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.prompt-highlight {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code.copypasteable {
|
|
||||||
display: block;
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 20px;
|
|
||||||
background: #282c37;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -149,7 +135,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message {
|
.flash-message {
|
||||||
background: #282c37;
|
background: #282c37;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,4 +1,20 @@
|
|||||||
module ApplicationCable
|
module ApplicationCable
|
||||||
class Channel < ActionCable::Channel::Base
|
class Channel < ActionCable::Channel::Base
|
||||||
|
protected
|
||||||
|
|
||||||
|
def hydrate_status(encoded_message)
|
||||||
|
message = ActiveSupport::JSON.decode(encoded_message)
|
||||||
|
|
||||||
|
return [nil, message] if message['type'] == 'delete'
|
||||||
|
|
||||||
|
status = Status.find_by(id: message['id'])
|
||||||
|
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
|
||||||
|
|
||||||
|
[status, message]
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter?(status)
|
||||||
|
!status.nil? && FeedManager.instance.filter?(:public, status, current_user.account)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
11
app/channels/hashtag_channel.rb
Normal file
11
app/channels/hashtag_channel.rb
Normal 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
|
@@ -1,19 +1,9 @@
|
|||||||
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
|
|
||||||
class PublicChannel < ApplicationCable::Channel
|
class PublicChannel < ApplicationCable::Channel
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from 'timeline:public', -> (encoded_message) do
|
stream_from 'timeline:public', lambda { |encoded_message|
|
||||||
message = ActiveSupport::JSON.decode(encoded_message)
|
status, message = hydrate_status(encoded_message)
|
||||||
|
next if filter?(status)
|
||||||
status = Status.find_by(id: message['id'])
|
|
||||||
next if status.nil? || current_user.account.blocking?(status.account) || (status.reblog? && current_user.account.blocking?(status.reblog.account))
|
|
||||||
|
|
||||||
message['message'] = FeedManager.instance.inline_render(current_user.account, status)
|
|
||||||
|
|
||||||
transmit message
|
transmit message
|
||||||
end
|
}
|
||||||
end
|
|
||||||
|
|
||||||
def unsubscribed
|
|
||||||
# Any cleanup needed when channel is unsubscribed
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -2,8 +2,4 @@ class TimelineChannel < ApplicationCable::Channel
|
|||||||
def subscribed
|
def subscribed
|
||||||
stream_from "timeline:#{current_user.account_id}"
|
stream_from "timeline:#{current_user.account_id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def unsubscribed
|
|
||||||
# Any cleanup needed when channel is unsubscribed
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
@@ -1,5 +1,16 @@
|
|||||||
class AboutController < ApplicationController
|
class AboutController < ApplicationController
|
||||||
|
before_action :set_body_classes
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
end
|
||||||
|
|
||||||
|
def terms
|
||||||
|
@state = 'TBD'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_body_classes
|
||||||
@body_classes = 'about-body'
|
@body_classes = 'about-body'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
class Api::V1::AccountsController < ApiController
|
class Api::V1::AccountsController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
|
||||||
|
before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
|
||||||
|
before_action :require_user!, except: [:show, :following, :followers, :statuses]
|
||||||
before_action :set_account, except: [:verify_credentials, :suggestions]
|
before_action :set_account, except: [:verify_credentials, :suggestions]
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -12,20 +15,48 @@ class Api::V1::AccountsController < ApiController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def following
|
def following
|
||||||
@following = @account.following
|
results = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
|
||||||
|
@accounts = Account.where(id: results.map(&:target_account_id)).with_counters.to_a
|
||||||
|
|
||||||
|
next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
|
||||||
|
prev_path = following_api_v1_account_url(since_id: results.first.id) if results.size > 0
|
||||||
|
|
||||||
|
set_pagination_headers(next_path, prev_path)
|
||||||
|
|
||||||
|
render action: :index
|
||||||
end
|
end
|
||||||
|
|
||||||
def followers
|
def followers
|
||||||
@followers = @account.followers
|
results = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
|
||||||
|
@accounts = Account.where(id: results.map(&:account_id)).with_counters.to_a
|
||||||
|
|
||||||
|
next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
|
||||||
|
prev_path = following_api_v1_account_url(since_id: results.first.id) if results.size > 0
|
||||||
|
|
||||||
|
set_pagination_headers(next_path, prev_path)
|
||||||
|
|
||||||
|
render action: :index
|
||||||
|
end
|
||||||
|
|
||||||
|
def common_followers
|
||||||
|
@accounts = @account.common_followers_with(current_user.account)
|
||||||
|
render action: :index
|
||||||
end
|
end
|
||||||
|
|
||||||
def suggestions
|
def suggestions
|
||||||
@accounts = FollowSuggestion.get(current_user.account_id)
|
@accounts = FollowSuggestion.get(current_user.account_id)
|
||||||
|
render action: :index
|
||||||
end
|
end
|
||||||
|
|
||||||
def statuses
|
def statuses
|
||||||
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
|
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a
|
||||||
|
|
||||||
set_maps(@statuses)
|
set_maps(@statuses)
|
||||||
|
|
||||||
|
next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT
|
||||||
|
prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) if @statuses.size > 0
|
||||||
|
|
||||||
|
set_pagination_headers(next_path, prev_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
def follow
|
def follow
|
||||||
|
@@ -1,11 +1,19 @@
|
|||||||
class Api::V1::FollowsController < ApiController
|
class Api::V1::FollowsController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :follow }
|
||||||
|
before_action :require_user!
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def create
|
def create
|
||||||
raise ActiveRecord::RecordNotFound if params[:uri].blank?
|
raise ActiveRecord::RecordNotFound if params[:uri].blank?
|
||||||
|
|
||||||
@account = FollowService.new.call(current_user.account, params[:uri].strip).try(:target_account)
|
@account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
|
||||||
render action: :show
|
render action: :show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def target_uri
|
||||||
|
params[:uri].strip.gsub(/\A@/, '')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
class Api::V1::MediaController < ApiController
|
class Api::V1::MediaController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :write }
|
||||||
|
before_action :require_user!
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@@ -1,16 +1,41 @@
|
|||||||
class Api::V1::StatusesController < ApiController
|
class Api::V1::StatusesController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
|
||||||
|
before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
|
||||||
|
before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by]
|
||||||
|
before_action :set_status, only: [:show, :context, :reblogged_by, :favourited_by]
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@status = Status.find(params[:id])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def context
|
def context
|
||||||
@status = Status.find(params[:id])
|
@context = OpenStruct.new({ ancestors: @status.ancestors, descendants: @status.descendants })
|
||||||
@ancestors = @status.ancestors
|
set_maps([@status] + @context[:ancestors] + @context[:descendants])
|
||||||
@descendants = @status.descendants
|
end
|
||||||
set_maps([@status] + @ancestors + @descendants)
|
|
||||||
|
def reblogged_by
|
||||||
|
results = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
|
||||||
|
@accounts = Account.where(id: results.map(&:account_id)).with_counters.to_a
|
||||||
|
|
||||||
|
next_path = reblogged_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
|
||||||
|
prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) if results.size > 0
|
||||||
|
|
||||||
|
set_pagination_headers(next_path, prev_path)
|
||||||
|
|
||||||
|
render action: :accounts
|
||||||
|
end
|
||||||
|
|
||||||
|
def favourited_by
|
||||||
|
results = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
|
||||||
|
@accounts = Account.where(id: results.map(&:account_id)).with_counters.to_a
|
||||||
|
|
||||||
|
next_path = favourited_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT
|
||||||
|
prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) if results.size > 0
|
||||||
|
|
||||||
|
set_pagination_headers(next_path, prev_path)
|
||||||
|
|
||||||
|
render action: :accounts
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -45,21 +70,9 @@ class Api::V1::StatusesController < ApiController
|
|||||||
render action: :show
|
render action: :show
|
||||||
end
|
end
|
||||||
|
|
||||||
def home
|
private
|
||||||
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
|
||||||
set_maps(@statuses)
|
|
||||||
render action: :index
|
|
||||||
end
|
|
||||||
|
|
||||||
def mentions
|
def set_status
|
||||||
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
@status = Status.find(params[:id])
|
||||||
set_maps(@statuses)
|
|
||||||
render action: :index
|
|
||||||
end
|
|
||||||
|
|
||||||
def public
|
|
||||||
@statuses = Status.as_public_timeline(current_user.account).paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
|
|
||||||
set_maps(@statuses)
|
|
||||||
render action: :index
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
59
app/controllers/api/v1/timelines_controller.rb
Normal file
59
app/controllers/api/v1/timelines_controller.rb
Normal 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
|
@@ -1,7 +1,13 @@
|
|||||||
class ApiController < ApplicationController
|
class ApiController < ApplicationController
|
||||||
|
DEFAULT_STATUSES_LIMIT = 20
|
||||||
|
DEFAULT_ACCOUNTS_LIMIT = 40
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
|
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
|
before_action :set_rate_limit_headers
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid do |e|
|
rescue_from ActiveRecord::RecordInvalid do |e|
|
||||||
render json: { error: e.to_s }, status: 422
|
render json: { error: e.to_s }, status: 422
|
||||||
end
|
end
|
||||||
@@ -22,14 +28,48 @@ class ApiController < ApplicationController
|
|||||||
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
|
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def doorkeeper_unauthorized_render_options(*)
|
||||||
|
{ json: { error: 'Not authorized' } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def doorkeeper_forbidden_render_options(*)
|
||||||
|
{ json: { error: 'This action is outside the authorized scopes' } }
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
|
def set_rate_limit_headers
|
||||||
|
return if request.env['rack.attack.throttle_data'].nil?
|
||||||
|
|
||||||
|
now = Time.now.utc
|
||||||
|
match_data = request.env['rack.attack.throttle_data']['api']
|
||||||
|
|
||||||
|
response.headers['X-RateLimit-Limit'] = match_data[:limit].to_s
|
||||||
|
response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s
|
||||||
|
response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_pagination_headers(next_path = nil, prev_path = nil)
|
||||||
|
links = []
|
||||||
|
links << [next_path, [['rel', 'next']]] if next_path
|
||||||
|
links << [prev_path, [['rel', 'prev']]] if prev_path
|
||||||
|
response.headers['Link'] = LinkHeader.new(links)
|
||||||
|
end
|
||||||
|
|
||||||
def current_resource_owner
|
def current_resource_owner
|
||||||
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
|
User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_user
|
def current_user
|
||||||
super || current_resource_owner
|
super || current_resource_owner
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_user!
|
||||||
|
current_resource_owner
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: 'This method requires an authenticated user' }, status: 422
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_empty
|
def render_empty
|
||||||
@@ -37,8 +77,14 @@ class ApiController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_maps(statuses)
|
def set_maps(statuses)
|
||||||
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact
|
if current_account.nil?
|
||||||
@reblogs_map = Status.reblogs_map(status_ids, current_user.account)
|
@reblogs_map = {}
|
||||||
@favourites_map = Status.favourites_map(status_ids, current_user.account)
|
@favourites_map = {}
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact.uniq
|
||||||
|
@reblogs_map = Status.reblogs_map(status_ids, current_account)
|
||||||
|
@favourites_map = Status.favourites_map(status_ids, current_account)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -15,6 +15,6 @@ class HomeController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_access_token
|
def find_or_create_access_token
|
||||||
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, nil, Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
|
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, 'read write follow', Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
12
app/controllers/oauth/authorizations_controller.rb
Normal file
12
app/controllers/oauth/authorizations_controller.rb
Normal 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
|
7
app/controllers/tags_controller.rb
Normal file
7
app/controllers/tags_controller.rb
Normal 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
|
@@ -47,6 +47,10 @@ module AtomBuilderHelper
|
|||||||
xml.author(&block)
|
xml.author(&block)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def category(xml, tag)
|
||||||
|
xml.category(term: tag.name)
|
||||||
|
end
|
||||||
|
|
||||||
def target(xml, &block)
|
def target(xml, &block)
|
||||||
xml['activity'].object(&block)
|
xml['activity'].object(&block)
|
||||||
end
|
end
|
||||||
@@ -186,6 +190,10 @@ module AtomBuilderHelper
|
|||||||
stream_entry.target.media_attachments.each do |media|
|
stream_entry.target.media_attachments.each do |media|
|
||||||
link_enclosure xml, media
|
link_enclosure xml, media
|
||||||
end
|
end
|
||||||
|
|
||||||
|
stream_entry.target.tags.each do |tag|
|
||||||
|
category xml, tag
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -198,6 +206,10 @@ module AtomBuilderHelper
|
|||||||
stream_entry.activity.media_attachments.each do |media|
|
stream_entry.activity.media_attachments.each do |media|
|
||||||
link_enclosure xml, media
|
link_enclosure xml, media
|
||||||
end
|
end
|
||||||
|
|
||||||
|
stream_entry.activity.tags.each do |tag|
|
||||||
|
category xml, tag
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@@ -10,6 +10,6 @@ module RoutingHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def full_asset_url(source)
|
def full_asset_url(source)
|
||||||
File.join(root_url, ActionController::Base.helpers.asset_url(source))
|
Rails.configuration.x.use_s3 ? source : File.join(root_url, ActionController::Base.helpers.asset_url(source))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
2
app/helpers/tags_helper.rb
Normal file
2
app/helpers/tags_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module TagsHelper
|
||||||
|
end
|
@@ -12,8 +12,12 @@ class FeedManager
|
|||||||
def filter?(timeline_type, status, receiver)
|
def filter?(timeline_type, status, receiver)
|
||||||
if timeline_type == :home
|
if timeline_type == :home
|
||||||
filter_from_home?(status, receiver)
|
filter_from_home?(status, receiver)
|
||||||
else
|
elsif timeline_type == :mentions
|
||||||
filter_from_mentions?(status, receiver)
|
filter_from_mentions?(status, receiver)
|
||||||
|
elsif timeline_type == :public
|
||||||
|
filter_from_public?(status, receiver)
|
||||||
|
else
|
||||||
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -23,8 +27,8 @@ class FeedManager
|
|||||||
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
|
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast(account_id, options = {})
|
def broadcast(timeline_id, options = {})
|
||||||
ActionCable.server.broadcast("timeline:#{account_id}", options)
|
ActionCable.server.broadcast("timeline:#{timeline_id}", options)
|
||||||
end
|
end
|
||||||
|
|
||||||
def trim(type, account_id)
|
def trim(type, account_id)
|
||||||
@@ -59,13 +63,35 @@ class FeedManager
|
|||||||
$redis
|
$redis
|
||||||
end
|
end
|
||||||
|
|
||||||
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
|
|
||||||
def filter_from_home?(status, receiver)
|
def filter_from_home?(status, receiver)
|
||||||
replied_to_user = status.reply? ? status.thread.account : nil
|
should_filter = false
|
||||||
(status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user))) || (status.reblog? && receiver.blocking?(status.reblog.account))
|
|
||||||
|
if status.reply? && !status.thread.account.nil? # Filter out if it's a reply
|
||||||
|
should_filter = !receiver.following?(status.thread.account) # and I'm not following the person it's a reply to
|
||||||
|
should_filter = should_filter && !(receiver.id == status.thread.account_id) # and it's not a reply to me
|
||||||
|
should_filter = should_filter && !(status.account_id == status.thread.account_id) # and it's not a self-reply
|
||||||
|
elsif status.reblog? # Filter out a reblog
|
||||||
|
should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person
|
||||||
|
end
|
||||||
|
|
||||||
|
should_filter
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_from_mentions?(status, receiver)
|
def filter_from_mentions?(status, receiver)
|
||||||
receiver.blocking?(status.account)
|
should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself
|
||||||
|
should_filter = should_filter || receiver.blocking?(status.account) # or it's from someone I blocked
|
||||||
|
should_filter
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_from_public?(status, receiver)
|
||||||
|
should_filter = receiver.blocking?(status.account)
|
||||||
|
|
||||||
|
if status.reply? && !status.thread.account.nil?
|
||||||
|
should_filter = should_filter || receiver.blocking?(status.thread.account)
|
||||||
|
elsif status.reblog?
|
||||||
|
should_filter = should_filter || receiver.blocking?(status.reblog.account)
|
||||||
|
end
|
||||||
|
|
||||||
|
should_filter
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -2,24 +2,35 @@ require 'singleton'
|
|||||||
|
|
||||||
class Formatter
|
class Formatter
|
||||||
include Singleton
|
include Singleton
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
include ActionView::Helpers::TextHelper
|
include ActionView::Helpers::TextHelper
|
||||||
include ActionView::Helpers::SanitizeHelper
|
include ActionView::Helpers::SanitizeHelper
|
||||||
|
|
||||||
def format(status)
|
def format(status)
|
||||||
return reformat(status) unless status.local?
|
return reformat(status.content) unless status.local?
|
||||||
|
|
||||||
html = status.text
|
html = status.text
|
||||||
html = encode(html)
|
html = encode(html)
|
||||||
html = simple_format(html, sanitize: false)
|
html = simple_format(html, sanitize: false)
|
||||||
html = link_urls(html)
|
html = link_urls(html)
|
||||||
html = link_mentions(html, status.mentions)
|
html = link_mentions(html, status.mentions)
|
||||||
|
html = link_hashtags(html)
|
||||||
|
|
||||||
html.html_safe
|
html.html_safe
|
||||||
end
|
end
|
||||||
|
|
||||||
def reformat(status)
|
def reformat(html)
|
||||||
sanitize(status.content, tags: %w(a br p), attributes: %w(href rel))
|
sanitize(html, tags: %w(a br p), attributes: %w(href rel))
|
||||||
|
end
|
||||||
|
|
||||||
|
def simplified_format(account)
|
||||||
|
return reformat(account.note) unless account.local?
|
||||||
|
|
||||||
|
html = encode(account.note)
|
||||||
|
html = link_urls(html)
|
||||||
|
|
||||||
|
html.html_safe
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -43,6 +54,17 @@ class Formatter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def link_hashtags(html)
|
||||||
|
html.gsub(Tag::HASHTAG_RE) do |match|
|
||||||
|
hashtag_html(match)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def hashtag_html(match)
|
||||||
|
prefix, affix = match.split('#')
|
||||||
|
"#{prefix}<a href=\"#{tag_url(affix.downcase)}\" class=\"mention hashtag\">#<span>#{affix}</span></a>"
|
||||||
|
end
|
||||||
|
|
||||||
def mention_html(match, account)
|
def mention_html(match, account)
|
||||||
"#{match.split('@').first}<a href=\"#{TagManager.instance.url_for(account)}\" class=\"mention\">@<span>#{account.username}</span></a>"
|
"#{match.split('@').first}<a href=\"#{TagManager.instance.url_for(account)}\" class=\"mention\">@<span>#{account.username}</span></a>"
|
||||||
end
|
end
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
include Targetable
|
include Targetable
|
||||||
|
|
||||||
MENTION_RE = /(?:^|\s|\.|>)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
|
MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
|
||||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||||
|
|
||||||
# Local users
|
# Local users
|
||||||
@@ -21,7 +21,7 @@ class Account < ApplicationRecord
|
|||||||
|
|
||||||
# Local user profile validations
|
# Local user profile validations
|
||||||
validates :display_name, length: { maximum: 30 }, if: 'local?'
|
validates :display_name, length: { maximum: 30 }, if: 'local?'
|
||||||
validates :note, length: { maximum: 124 }, if: 'local?'
|
validates :note, length: { maximum: 160 }, if: 'local?'
|
||||||
|
|
||||||
# Timelines
|
# Timelines
|
||||||
has_many :stream_entries, inverse_of: :account, dependent: :destroy
|
has_many :stream_entries, inverse_of: :account, dependent: :destroy
|
||||||
@@ -33,12 +33,12 @@ class Account < ApplicationRecord
|
|||||||
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
|
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
|
||||||
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
|
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
|
||||||
|
|
||||||
has_many :following, -> { order('follows.created_at desc') }, through: :active_relationships, source: :target_account
|
has_many :following, -> { order('follows.id desc') }, through: :active_relationships, source: :target_account
|
||||||
has_many :followers, -> { order('follows.created_at desc') }, through: :passive_relationships, source: :account
|
has_many :followers, -> { order('follows.id desc') }, through: :passive_relationships, source: :account
|
||||||
|
|
||||||
# Block relationships
|
# Block relationships
|
||||||
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
|
has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
|
||||||
has_many :blocking, -> { order('blocks.created_at desc') }, through: :block_relationships, source: :target_account
|
has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
|
||||||
|
|
||||||
has_many :media_attachments, dependent: :destroy
|
has_many :media_attachments, dependent: :destroy
|
||||||
|
|
||||||
@@ -112,6 +112,8 @@ class Account < ApplicationRecord
|
|||||||
def avatar_remote_url=(url)
|
def avatar_remote_url=(url)
|
||||||
self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
|
self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
|
||||||
self[:avatar_remote_url] = url
|
self[:avatar_remote_url] = url
|
||||||
|
rescue OpenURI::HTTPError
|
||||||
|
#
|
||||||
end
|
end
|
||||||
|
|
||||||
def object_type
|
def object_type
|
||||||
@@ -122,37 +124,48 @@ class Account < ApplicationRecord
|
|||||||
username
|
username
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_local!(username)
|
def common_followers_with(other_account)
|
||||||
|
results = Neography::Rest.new.execute_query('MATCH (a {account_id: {a_id}})-[:follows]->(b)-[:follows]->(c {account_id: {c_id}}) RETURN b.account_id', a_id: id, c_id: other_account.id)
|
||||||
|
ids = results['data'].map(&:first)
|
||||||
|
accounts = Account.where(id: ids).with_counters.limit(20).map { |a| [a.id, a] }.to_h
|
||||||
|
ids.map { |id| accounts[id] }.compact
|
||||||
|
rescue Neography::NeographyError, Excon::Error::Socket
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def find_local!(username)
|
||||||
find_remote!(username, nil)
|
find_remote!(username, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_remote!(username, domain)
|
def find_remote!(username, domain)
|
||||||
where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take!
|
where(arel_table[:username].matches(username)).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain)).take!
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_local(username)
|
def find_local(username)
|
||||||
find_local!(username)
|
find_local!(username)
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_remote(username, domain)
|
def find_remote(username, domain)
|
||||||
find_remote!(username, domain)
|
find_remote!(username, domain)
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.following_map(target_account_ids, account_id)
|
def following_map(target_account_ids, account_id)
|
||||||
Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h
|
Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.followed_by_map(target_account_ids, account_id)
|
def followed_by_map(target_account_ids, account_id)
|
||||||
Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h
|
Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.blocking_map(target_account_ids, account_id)
|
def blocking_map(target_account_ids, account_id)
|
||||||
Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h
|
Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
before_create do
|
before_create do
|
||||||
if local?
|
if local?
|
||||||
|
@@ -2,11 +2,11 @@ module Paginable
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
scope :paginate_by_max_id, -> (limit, max_id = nil, since_id = nil) {
|
||||||
query = order('id desc').limit(limit)
|
query = order(arel_table[:id].desc).limit(limit)
|
||||||
query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank?
|
query = query.where(arel_table[:id].lt(max_id)) unless max_id.blank?
|
||||||
query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank?
|
query = query.where(arel_table[:id].gt(since_id)) unless since_id.blank?
|
||||||
query
|
query
|
||||||
end
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
class Favourite < ApplicationRecord
|
class Favourite < ApplicationRecord
|
||||||
|
include Paginable
|
||||||
include Streamable
|
include Streamable
|
||||||
|
|
||||||
belongs_to :account, inverse_of: :favourites
|
belongs_to :account, inverse_of: :favourites
|
||||||
belongs_to :status, inverse_of: :favourites
|
belongs_to :status, inverse_of: :favourites, touch: true
|
||||||
|
|
||||||
validates :status_id, uniqueness: { scope: :account_id }
|
validates :status_id, uniqueness: { scope: :account_id }
|
||||||
|
|
||||||
|
@@ -7,16 +7,18 @@ class Feed
|
|||||||
def get(limit, max_id = nil, since_id = nil)
|
def get(limit, max_id = nil, since_id = nil)
|
||||||
max_id = '+inf' if max_id.blank?
|
max_id = '+inf' if max_id.blank?
|
||||||
since_id = '-inf' if since_id.blank?
|
since_id = '-inf' if since_id.blank?
|
||||||
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).collect(&:last).map(&:to_i)
|
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
|
||||||
status_map = {}
|
|
||||||
|
|
||||||
# If we're after most recent items and none are there, we need to precompute the feed
|
# If we're after most recent items and none are there, we need to precompute the feed
|
||||||
if unhydrated.empty? && max_id == '+inf'
|
if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
|
||||||
PrecomputeFeedService.new.call(@type, @account, limit)
|
RegenerationWorker.perform_async(@account.id, @type)
|
||||||
|
@statuses = Status.send("as_#{@type}_timeline", @account).paginate_by_max_id(limit, nil, nil)
|
||||||
else
|
else
|
||||||
Status.where(id: unhydrated).with_includes.with_counters.each { |status| status_map[status.id] = status }
|
status_map = Status.where(id: unhydrated).with_includes.with_counters.map { |status| [status.id, status] }.to_h
|
||||||
unhydrated.map { |id| status_map[id] }.compact
|
@statuses = unhydrated.map { |id| status_map[id] }.compact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@statuses
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
class Follow < ApplicationRecord
|
class Follow < ApplicationRecord
|
||||||
|
include Paginable
|
||||||
include Streamable
|
include Streamable
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
class FollowSuggestion
|
class FollowSuggestion
|
||||||
def self.get(for_account_id, limit = 10)
|
class << self
|
||||||
|
def get(for_account_id, limit = 10)
|
||||||
neo = Neography::Rest.new
|
neo = Neography::Rest.new
|
||||||
|
|
||||||
query = <<END
|
query = <<END
|
||||||
START a=node:account_index(Account={id})
|
MATCH (a {account_id: {id}})-[:follows]->(b)-[:follows]->(c)
|
||||||
MATCH (a)-[:follows]->(b)-[:follows]->(c)
|
|
||||||
WHERE a <> c
|
WHERE a <> c
|
||||||
AND NOT (a)-[:follows]->(c)
|
AND NOT (a)-[:follows]->(c)
|
||||||
RETURN DISTINCT c.account_id, count(b), c.nodeRank
|
RETURN DISTINCT c.account_id, count(b), c.nodeRank
|
||||||
@@ -32,14 +32,11 @@ END
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def self.fallback(for_account_id, limit)
|
def fallback(for_account_id, limit)
|
||||||
neo = Neography::Rest.new
|
neo = Neography::Rest.new
|
||||||
|
|
||||||
query = <<END
|
query = <<END
|
||||||
START a=node:account_index(Account={id})
|
|
||||||
MATCH (b)
|
MATCH (b)
|
||||||
WHERE a <> b
|
|
||||||
AND NOT (a)-[:follows]->(b)
|
|
||||||
RETURN b.account_id
|
RETURN b.account_id
|
||||||
ORDER BY b.nodeRank DESC
|
ORDER BY b.nodeRank DESC
|
||||||
LIMIT {limit}
|
LIMIT {limit}
|
||||||
@@ -48,3 +45,4 @@ END
|
|||||||
neo.execute_query(query, id: for_account_id, limit: limit)
|
neo.execute_query(query, id: for_account_id, limit: limit)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
@@ -20,6 +20,8 @@ class MediaAttachment < ApplicationRecord
|
|||||||
|
|
||||||
def file_remote_url=(url)
|
def file_remote_url=(url)
|
||||||
self.file = URI.parse(url)
|
self.file = URI.parse(url)
|
||||||
|
rescue OpenURI::HTTPError
|
||||||
|
#
|
||||||
end
|
end
|
||||||
|
|
||||||
def image?
|
def image?
|
||||||
@@ -34,8 +36,10 @@ class MediaAttachment < ApplicationRecord
|
|||||||
image? ? 'image' : 'video'
|
image? ? 'image' : 'video'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
private
|
private
|
||||||
def self.file_styles(f)
|
|
||||||
|
def file_styles(f)
|
||||||
if f.instance.image?
|
if f.instance.image?
|
||||||
{
|
{
|
||||||
original: '100%',
|
original: '100%',
|
||||||
@@ -43,11 +47,6 @@ private
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
original: {
|
|
||||||
convert_options: {},
|
|
||||||
format: 'webm'
|
|
||||||
},
|
|
||||||
|
|
||||||
small: {
|
small: {
|
||||||
convert_options: {
|
convert_options: {
|
||||||
output: {
|
output: {
|
||||||
@@ -61,3 +60,4 @@ private
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
@@ -5,13 +5,14 @@ class Status < ApplicationRecord
|
|||||||
belongs_to :account, -> { with_counters }, inverse_of: :statuses
|
belongs_to :account, -> { with_counters }, inverse_of: :statuses
|
||||||
|
|
||||||
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
|
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
|
||||||
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
|
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, touch: true
|
||||||
|
|
||||||
has_many :favourites, inverse_of: :status, dependent: :destroy
|
has_many :favourites, inverse_of: :status, dependent: :destroy
|
||||||
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
|
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
|
||||||
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
|
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
|
||||||
has_many :mentions, dependent: :destroy
|
has_many :mentions, dependent: :destroy
|
||||||
has_many :media_attachments, dependent: :destroy
|
has_many :media_attachments, dependent: :destroy
|
||||||
|
has_and_belongs_to_many :tags
|
||||||
|
|
||||||
validates :account, presence: true
|
validates :account, presence: true
|
||||||
validates :uri, uniqueness: true, unless: 'local?'
|
validates :uri, uniqueness: true, unless: 'local?'
|
||||||
@@ -21,7 +22,7 @@ class Status < ApplicationRecord
|
|||||||
default_scope { order('id desc') }
|
default_scope { order('id desc') }
|
||||||
|
|
||||||
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
|
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
|
||||||
scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
|
scope :with_includes, -> { includes(:account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
|
||||||
|
|
||||||
def local?
|
def local?
|
||||||
uri.nil?
|
uri.nil?
|
||||||
@@ -77,26 +78,60 @@ class Status < ApplicationRecord
|
|||||||
ids.map { |id| statuses[id].first }
|
ids.map { |id| statuses[id].first }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.as_home_timeline(account)
|
class << self
|
||||||
|
def as_home_timeline(account)
|
||||||
where(account: [account] + account.following).with_includes.with_counters
|
where(account: [account] + account.following).with_includes.with_counters
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.as_mentions_timeline(account)
|
def as_mentions_timeline(account)
|
||||||
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
|
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.as_public_timeline(account)
|
def as_public_timeline(account = nil)
|
||||||
joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id').where('(reblogs.account_id IS NULL OR reblogs.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)) AND statuses.account_id NOT IN (SELECT target_account_id FROM blocks WHERE account_id = ?)', account.id, account.id).with_includes.with_counters
|
query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id').where('accounts.silenced = FALSE')
|
||||||
|
|
||||||
|
unless account.nil?
|
||||||
|
query = filter_timeline(query, account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.favourites_map(status_ids, account_id)
|
query.with_includes.with_counters
|
||||||
|
end
|
||||||
|
|
||||||
|
def as_tag_timeline(tag, account = nil)
|
||||||
|
query = tag.statuses
|
||||||
|
.joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
|
||||||
|
.where('accounts.silenced = FALSE')
|
||||||
|
|
||||||
|
unless account.nil?
|
||||||
|
query = filter_timeline(query, account)
|
||||||
|
end
|
||||||
|
|
||||||
|
query.with_includes.with_counters
|
||||||
|
end
|
||||||
|
|
||||||
|
def favourites_map(status_ids, account_id)
|
||||||
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
|
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.reblogs_map(status_ids, account_id)
|
def reblogs_map(status_ids, account_id)
|
||||||
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
|
select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def filter_timeline(query, account)
|
||||||
|
blocked = Block.where(account: account).pluck(:target_account_id)
|
||||||
|
return query if blocked.empty?
|
||||||
|
|
||||||
|
query
|
||||||
|
.joins('LEFT OUTER JOIN statuses AS parents ON statuses.in_reply_to_id = parents.id')
|
||||||
|
.joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id')
|
||||||
|
.where('parents.account_id NOT IN (?)', blocked)
|
||||||
|
.where('statuses.account_id NOT IN (?)', blocked)
|
||||||
|
.where('(reblogs.id IS NULL OR reblogs.account_id NOT IN (?))', blocked)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
before_validation do
|
before_validation do
|
||||||
text.strip!
|
text.strip!
|
||||||
end
|
end
|
||||||
|
@@ -10,7 +10,7 @@ class StreamEntry < ApplicationRecord
|
|||||||
|
|
||||||
validates :account, :activity, presence: true
|
validates :account, :activity, presence: true
|
||||||
|
|
||||||
STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
|
STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
|
||||||
|
|
||||||
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) }
|
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) }
|
||||||
|
|
||||||
|
11
app/models/tag.rb
Normal file
11
app/models/tag.rb
Normal 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
|
@@ -7,7 +7,7 @@ class User < ApplicationRecord
|
|||||||
validates :account, presence: true
|
validates :account, presence: true
|
||||||
|
|
||||||
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
|
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
|
||||||
scope :recent, -> { order('created_at desc') }
|
scope :recent, -> { order('id desc') }
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { where(admin: true) }
|
||||||
|
|
||||||
has_settings do |s|
|
has_settings do |s|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
class BlockDomainService < BaseService
|
class BlockDomainService < BaseService
|
||||||
def call(domain)
|
def call(domain)
|
||||||
block = DomainBlock.find_or_create_by!(domain: domain)
|
DomainBlock.find_or_create_by!(domain: domain)
|
||||||
|
|
||||||
Account.where(domain: domain).find_each do |account|
|
Account.where(domain: domain).find_each do |account|
|
||||||
if account.subscribed?
|
if account.subscribed?
|
||||||
|
@@ -5,31 +5,49 @@ class FanOutOnWriteService < BaseService
|
|||||||
deliver_to_self(status) if status.account.local?
|
deliver_to_self(status) if status.account.local?
|
||||||
deliver_to_followers(status)
|
deliver_to_followers(status)
|
||||||
deliver_to_mentioned(status)
|
deliver_to_mentioned(status)
|
||||||
|
|
||||||
|
return if status.account.silenced?
|
||||||
|
|
||||||
|
deliver_to_hashtags(status)
|
||||||
deliver_to_public(status)
|
deliver_to_public(status)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def deliver_to_self(status)
|
def deliver_to_self(status)
|
||||||
|
Rails.logger.debug "Delivering status #{status.id} to author"
|
||||||
FeedManager.instance.push(:home, status.account, status)
|
FeedManager.instance.push(:home, status.account, status)
|
||||||
end
|
end
|
||||||
|
|
||||||
def deliver_to_followers(status)
|
def deliver_to_followers(status)
|
||||||
status.account.followers.each do |follower|
|
Rails.logger.debug "Delivering status #{status.id} to followers"
|
||||||
|
|
||||||
|
status.account.followers.find_each do |follower|
|
||||||
next if !follower.local? || FeedManager.instance.filter?(:home, status, follower)
|
next if !follower.local? || FeedManager.instance.filter?(:home, status, follower)
|
||||||
FeedManager.instance.push(:home, follower, status)
|
FeedManager.instance.push(:home, follower, status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def deliver_to_mentioned(status)
|
def deliver_to_mentioned(status)
|
||||||
status.mentions.each do |mention|
|
Rails.logger.debug "Delivering status #{status.id} to mentioned accounts"
|
||||||
|
|
||||||
|
status.mentions.includes(:account).each do |mention|
|
||||||
mentioned_account = mention.account
|
mentioned_account = mention.account
|
||||||
next if !mentioned_account.local? || mentioned_account.id == status.account_id || FeedManager.instance.filter?(:mentions, status, mentioned_account)
|
next if !mentioned_account.local? || FeedManager.instance.filter?(:mentions, status, mentioned_account)
|
||||||
FeedManager.instance.push(:mentions, mentioned_account, status)
|
FeedManager.instance.push(:mentions, mentioned_account, status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def deliver_to_hashtags(status)
|
||||||
|
Rails.logger.debug "Delivering status #{status.id} to hashtags"
|
||||||
|
|
||||||
|
status.tags.find_each do |tag|
|
||||||
|
FeedManager.instance.broadcast("hashtag:#{tag.name}", type: 'update', id: status.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def deliver_to_public(status)
|
def deliver_to_public(status)
|
||||||
FeedManager.instance.broadcast(:public, id: status.id)
|
Rails.logger.debug "Delivering status #{status.id} to public timeline"
|
||||||
|
FeedManager.instance.broadcast(:public, type: 'update', id: status.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -3,6 +3,11 @@ class FetchAtomService < BaseService
|
|||||||
response = http_client.head(url)
|
response = http_client.head(url)
|
||||||
|
|
||||||
Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
|
Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
|
||||||
|
|
||||||
|
response = http_client.get(url) if response.code == 405
|
||||||
|
|
||||||
|
Rails.logger.debug "Remote status GET request returned code #{response.code}"
|
||||||
|
|
||||||
return nil if response.code != 200
|
return nil if response.code != 200
|
||||||
|
|
||||||
if response.mime_type == 'application/atom+xml'
|
if response.mime_type == 'application/atom+xml'
|
||||||
|
@@ -19,9 +19,11 @@ class FetchRemoteAccountService < BaseService
|
|||||||
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
||||||
|
|
||||||
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
||||||
rescue TypeError => e
|
rescue TypeError
|
||||||
Rails.logger.debug "Unparseable URL given: #{url}"
|
Rails.logger.debug "Unparseable URL given: #{url}"
|
||||||
|
nil
|
||||||
rescue Nokogiri::XML::XPath::SyntaxError
|
rescue Nokogiri::XML::XPath::SyntaxError
|
||||||
Rails.logger.debug "Invalid XML or missing namespace"
|
Rails.logger.debug "Invalid XML or missing namespace"
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -9,7 +9,7 @@ class FetchRemoteStatusService < BaseService
|
|||||||
private
|
private
|
||||||
|
|
||||||
def process_atom(url, body)
|
def process_atom(url, body)
|
||||||
Rails.logger.debug 'Processing Atom for remote status'
|
Rails.logger.debug "Processing Atom for remote status at #{url}"
|
||||||
|
|
||||||
xml = Nokogiri::XML(body)
|
xml = Nokogiri::XML(body)
|
||||||
account = extract_author(url, xml)
|
account = extract_author(url, xml)
|
||||||
@@ -33,5 +33,6 @@ class FetchRemoteStatusService < BaseService
|
|||||||
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
||||||
rescue Nokogiri::XML::XPath::SyntaxError
|
rescue Nokogiri::XML::XPath::SyntaxError
|
||||||
Rails.logger.debug "Invalid XML or missing namespace"
|
Rails.logger.debug "Invalid XML or missing namespace"
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -15,16 +15,25 @@ class FollowRemoteAccountService < BaseService
|
|||||||
return nil if DomainBlock.blocked?(domain)
|
return nil if DomainBlock.blocked?(domain)
|
||||||
|
|
||||||
account = Account.find_remote(username, domain)
|
account = Account.find_remote(username, domain)
|
||||||
|
|
||||||
return account unless account.nil?
|
return account unless account.nil?
|
||||||
|
|
||||||
Rails.logger.debug "Creating new remote account for #{uri}"
|
Rails.logger.debug "Looking up webfinger for #{uri}"
|
||||||
|
|
||||||
account = Account.new(username: username, domain: domain)
|
account = Account.new(username: username, domain: domain)
|
||||||
|
|
||||||
data = Goldfinger.finger("acct:#{uri}")
|
data = Goldfinger.finger("acct:#{uri}")
|
||||||
|
|
||||||
raise Goldfinger::Error, 'Missing resource links' if data.link('http://schemas.google.com/g/2010#updates-from').nil? || data.link('salmon').nil? || data.link('http://webfinger.net/rel/profile-page').nil? || data.link('magic-public-key').nil?
|
raise Goldfinger::Error, 'Missing resource links' if data.link('http://schemas.google.com/g/2010#updates-from').nil? || data.link('salmon').nil? || data.link('http://webfinger.net/rel/profile-page').nil? || data.link('magic-public-key').nil?
|
||||||
|
|
||||||
|
confirmed_username, confirmed_domain = data.subject.gsub(/\Aacct:/, '').split('@')
|
||||||
|
|
||||||
|
return Account.find_local(confirmed_username) if TagManager.instance.local_domain?(confirmed_domain)
|
||||||
|
|
||||||
|
confirmed_account = Account.find_remote(confirmed_username, confirmed_domain)
|
||||||
|
return confirmed_account unless confirmed_account.nil?
|
||||||
|
|
||||||
|
Rails.logger.debug "Creating new remote account for #{uri}"
|
||||||
|
|
||||||
account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href
|
account.remote_url = data.link('http://schemas.google.com/g/2010#updates-from').href
|
||||||
account.salmon_url = data.link('salmon').href
|
account.salmon_url = data.link('salmon').href
|
||||||
account.url = data.link('http://webfinger.net/rel/profile-page').href
|
account.url = data.link('http://webfinger.net/rel/profile-page').href
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user