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:
|
||||
Enabled: true
|
||||
|
||||
Metrics/LineLength:
|
||||
Enabled: false
|
||||
|
||||
Style/PerlBackrefs:
|
||||
AutoCorrect: false
|
||||
|
||||
Style/ClassAndModuleChildren:
|
||||
Enabled: false
|
||||
|
||||
Documentation:
|
||||
Metrics/BlockNesting:
|
||||
Max: 2
|
||||
|
||||
Metrics/LineLength:
|
||||
AllowURI: true
|
||||
Enabled: false
|
||||
|
||||
Metrics/MethodLength:
|
||||
CountComments: false
|
||||
Max: 10
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Max: 100
|
||||
|
||||
Metrics/ParameterLists:
|
||||
Max: 4
|
||||
CountKeywordArgs: true
|
||||
|
||||
Style/AccessModifierIndentation:
|
||||
EnforcedStyle: indent
|
||||
|
||||
Style/CollectionMethods:
|
||||
Enabled: true
|
||||
PreferredMethods:
|
||||
find_all: 'select'
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
||||
Style/DoubleNegation:
|
||||
Enabled: false
|
||||
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: false
|
||||
|
||||
Style/SpaceInsideHashLiteralBraces:
|
||||
EnforcedStyle: space
|
||||
|
||||
Style/TrailingCommaInLiteral:
|
||||
EnforcedStyleForMultiline: 'comma'
|
||||
|
||||
Style/RegexpLiteral:
|
||||
Enabled: false
|
||||
|
||||
AllCops:
|
||||
TargetRubyVersion: 2.2
|
||||
Exclude:
|
||||
- 'spec/**/*'
|
||||
- 'db/**/*'
|
||||
- 'app/views/**/*'
|
||||
- 'config/**/*'
|
||||
|
6
Gemfile
6
Gemfile
@@ -4,7 +4,6 @@ gem 'rails', '5.0.0.1'
|
||||
gem 'sass-rails', '~> 5.0'
|
||||
gem 'uglifier', '>= 1.3.0'
|
||||
gem 'coffee-rails', '~> 4.1.0'
|
||||
gem 'therubyracer', platforms: :ruby
|
||||
gem 'jquery-rails'
|
||||
gem 'jbuilder', '~> 2.0'
|
||||
gem 'sdoc', '~> 0.4.0', group: :doc
|
||||
@@ -12,11 +11,13 @@ gem 'puma'
|
||||
|
||||
gem 'hamlit-rails'
|
||||
gem 'pg'
|
||||
gem 'pghero'
|
||||
gem 'dotenv-rails'
|
||||
gem 'font-awesome-rails'
|
||||
|
||||
gem 'paperclip', '~> 4.3'
|
||||
gem 'paperclip-av-transcoder'
|
||||
gem 'aws-sdk', '< 2.0'
|
||||
|
||||
gem 'http'
|
||||
gem 'httplog'
|
||||
@@ -37,12 +38,14 @@ gem 'htmlentities'
|
||||
gem 'simple_form'
|
||||
gem 'will_paginate'
|
||||
gem 'rack-attack'
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'sidekiq'
|
||||
gem 'ledermann-rails-settings'
|
||||
gem 'neography'
|
||||
|
||||
gem 'react-rails'
|
||||
gem 'browserify-rails'
|
||||
gem 'autoprefixer-rails'
|
||||
|
||||
group :development, :test do
|
||||
gem 'rspec-rails'
|
||||
@@ -69,4 +72,5 @@ end
|
||||
group :production do
|
||||
gem 'rails_12factor'
|
||||
gem 'lograge'
|
||||
gem 'redis-rails'
|
||||
end
|
||||
|
39
Gemfile.lock
39
Gemfile.lock
@@ -42,8 +42,15 @@ GEM
|
||||
addressable (2.4.0)
|
||||
arel (7.1.1)
|
||||
ast (2.3.0)
|
||||
autoprefixer-rails (6.5.0.2)
|
||||
execjs
|
||||
av (0.9.0)
|
||||
cocaine (~> 0.5.3)
|
||||
aws-sdk (1.66.0)
|
||||
aws-sdk-v1 (= 1.66.0)
|
||||
aws-sdk-v1 (1.66.0)
|
||||
json (~> 1.4)
|
||||
nokogiri (>= 1.4.4)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
babel-source (>= 4.0, < 6)
|
||||
@@ -150,7 +157,6 @@ GEM
|
||||
activerecord (>= 3.1)
|
||||
letter_opener (1.4.1)
|
||||
launchy (~> 2.2)
|
||||
libv8 (3.16.14.15)
|
||||
link_header (0.0.8)
|
||||
lograge (0.4.1)
|
||||
actionpack (>= 4, < 5.1)
|
||||
@@ -197,6 +203,8 @@ GEM
|
||||
parser (2.3.1.2)
|
||||
ast (~> 2.2)
|
||||
pg (0.18.4)
|
||||
pghero (1.6.2)
|
||||
activerecord
|
||||
powerpack (0.1.1)
|
||||
pry (0.10.4)
|
||||
coderay (~> 1.1.0)
|
||||
@@ -205,11 +213,12 @@ GEM
|
||||
pry-rails (0.3.4)
|
||||
pry (>= 0.9.10)
|
||||
puma (3.6.0)
|
||||
rabl (0.13.0)
|
||||
rabl (0.13.1)
|
||||
activesupport (>= 2.3.14)
|
||||
rack (2.0.1)
|
||||
rack-attack (5.0.1)
|
||||
rack
|
||||
rack-cors (0.4.0)
|
||||
rack-protection (1.5.3)
|
||||
rack
|
||||
rack-test (0.6.3)
|
||||
@@ -256,7 +265,22 @@ GEM
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
redis (3.3.1)
|
||||
ref (2.0.0)
|
||||
redis-actionpack (5.0.0)
|
||||
actionpack (>= 4.0.0, < 6)
|
||||
redis-rack (~> 2.0.0.pre)
|
||||
redis-store (~> 1.2.0.pre)
|
||||
redis-activesupport (5.0.1)
|
||||
activesupport (>= 3, < 6)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-rack (2.0.0)
|
||||
rack (~> 2.0)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-rails (5.0.1)
|
||||
redis-actionpack (~> 5.0.0)
|
||||
redis-activesupport (~> 5.0.0)
|
||||
redis-store (~> 1.2.0)
|
||||
redis-store (1.2.0)
|
||||
redis (>= 2.2)
|
||||
responders (2.3.0)
|
||||
railties (>= 4.2.0, < 5.1)
|
||||
rspec (3.5.0)
|
||||
@@ -324,9 +348,6 @@ GEM
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
temple (0.7.7)
|
||||
therubyracer (0.12.2)
|
||||
libv8 (~> 3.16.14.0)
|
||||
ref
|
||||
thor (0.19.1)
|
||||
thread_safe (0.3.5)
|
||||
tilt (2.0.5)
|
||||
@@ -356,6 +377,8 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
active_record_query_trace
|
||||
addressable
|
||||
autoprefixer-rails
|
||||
aws-sdk (< 2.0)
|
||||
better_errors
|
||||
binding_of_caller
|
||||
browserify-rails
|
||||
@@ -387,15 +410,18 @@ DEPENDENCIES
|
||||
paperclip (~> 4.3)
|
||||
paperclip-av-transcoder
|
||||
pg
|
||||
pghero
|
||||
pry-rails
|
||||
puma
|
||||
rabl
|
||||
rack-attack
|
||||
rack-cors
|
||||
rails (= 5.0.0.1)
|
||||
rails_12factor
|
||||
rails_autolink
|
||||
react-rails
|
||||
redis (~> 3.2)
|
||||
redis-rails
|
||||
rspec-rails
|
||||
rspec-sidekiq
|
||||
rubocop
|
||||
@@ -404,7 +430,6 @@ DEPENDENCIES
|
||||
sidekiq
|
||||
simple_form
|
||||
simplecov
|
||||
therubyracer
|
||||
uglifier (>= 1.3.0)
|
||||
webmock
|
||||
will_paginate
|
||||
|
18
README.md
18
README.md
@@ -7,11 +7,13 @@ Mastodon
|
||||
[travis]: https://travis-ci.org/Gargron/mastodon
|
||||
[code_climate]: https://codeclimate.com/github/Gargron/mastodon
|
||||
|
||||
Mastodon is a federated microblogging engine. An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||
Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly.
|
||||
|
||||
An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||
|
||||
Click on the screenshot to watch a demo of the UI:
|
||||
|
||||
[][youtube_demo]
|
||||
[][youtube_demo]
|
||||
|
||||
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
|
||||
|
||||
@@ -21,12 +23,13 @@ If you would like, you can [support the development of this project on Patreon][
|
||||
|
||||
[patreon]: https://www.patreon.com/user?u=619786
|
||||
|
||||
**Current status of the project is early development**
|
||||
|
||||
## Resources
|
||||
|
||||
- [List of Mastodon instances](https://github.com/Gargron/mastodon/wiki/List-of-Mastodon-instances)
|
||||
- [Use this tool to find Twitter friends on Mastodon](https://mastodon-bridge.herokuapp.com)
|
||||
- [API overview](https://github.com/Gargron/mastodon/wiki/API)
|
||||
- [How to use the API via cURL/oAuth](https://github.com/Gargron/mastodon/wiki/Testing-with-cURL)
|
||||
- [Frequently Asked Questions](https://github.com/Gargron/mastodon/wiki/FAQ)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -113,3 +116,10 @@ Which will re-create the updated containers, leaving databases and data as is. D
|
||||
### Contributing
|
||||
|
||||
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository. This section may be updated with more details in the future.
|
||||
|
||||
### Extra credits
|
||||
|
||||
- The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis
|
||||
- The error page image courtesy of [Dopatwo](https://www.youtube.com/user/dopatwo)
|
||||
|
||||

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