Compare commits
49 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9712d8ffb3 | ||
|
3319473b2c | ||
|
2febc6ed65 | ||
|
f254da17f6 | ||
|
fe77921e47 | ||
|
b078885414 | ||
|
feda1e6554 | ||
|
70e9dd0b5b | ||
|
7b9a4af311 | ||
|
2c9e672ee2 | ||
|
9d59d7b463 | ||
|
a488b05726 | ||
|
fc198a8b4c | ||
|
b3c7c8700d | ||
|
188c6f326b | ||
|
d74da1a89a | ||
|
f06f295890 | ||
|
9fd3d7b6cd | ||
|
a0f85774c4 | ||
|
6d7290f47c | ||
|
31a0202546 | ||
|
2d4f9ecebf | ||
|
be86d4e0a3 | ||
|
1fce687f8e | ||
|
4909bbf415 | ||
|
b36197c307 | ||
|
492224b93f | ||
|
96ad9b0db1 | ||
|
62b057b085 | ||
|
ef2b50c9ac | ||
|
a41c3487bd | ||
|
a4f7eca5fa | ||
|
927333f4f8 | ||
|
e4aebad35a | ||
|
2603c7e69f | ||
|
e63aebff7a | ||
|
45a96e899e | ||
|
4f9b7432dd | ||
|
3f75f52285 | ||
|
7e14eefc81 | ||
|
210362e665 | ||
|
c6b0311b86 | ||
|
0bd4608ad1 | ||
|
693383234c | ||
|
e9bc4a4a08 | ||
|
62b384824d | ||
|
15f51dbf8c | ||
|
fc7b982b67 | ||
|
1215ab57ff |
14
.rubocop.yml
Normal file
14
.rubocop.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Rails:
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
|
Metrics/LineLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Style/PerlBackrefs:
|
||||||
|
AutoCorrect: false
|
||||||
|
|
||||||
|
Style/ClassAndModuleChildren:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
Documentation:
|
||||||
|
Enabled: false
|
1
Gemfile
1
Gemfile
@@ -19,6 +19,7 @@ gem 'paperclip', '~> 4.3'
|
|||||||
gem 'paperclip-av-transcoder'
|
gem 'paperclip-av-transcoder'
|
||||||
|
|
||||||
gem 'http'
|
gem 'http'
|
||||||
|
gem 'httplog'
|
||||||
gem 'addressable'
|
gem 'addressable'
|
||||||
gem 'nokogiri'
|
gem 'nokogiri'
|
||||||
gem 'link_header'
|
gem 'link_header'
|
||||||
|
16
Gemfile.lock
16
Gemfile.lock
@@ -74,6 +74,7 @@ GEM
|
|||||||
coffee-script-source
|
coffee-script-source
|
||||||
execjs
|
execjs
|
||||||
coffee-script-source (1.10.0)
|
coffee-script-source (1.10.0)
|
||||||
|
colorize (0.8.1)
|
||||||
concurrent-ruby (1.0.2)
|
concurrent-ruby (1.0.2)
|
||||||
connection_pool (2.2.0)
|
connection_pool (2.2.0)
|
||||||
crack (0.4.3)
|
crack (0.4.3)
|
||||||
@@ -106,9 +107,9 @@ GEM
|
|||||||
ruby-progressbar (~> 1.4)
|
ruby-progressbar (~> 1.4)
|
||||||
globalid (0.3.7)
|
globalid (0.3.7)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
goldfinger (1.0.3)
|
goldfinger (1.0.5)
|
||||||
addressable (~> 2.4)
|
addressable (~> 2.4)
|
||||||
http (~> 1.0)
|
http (~> 2.0)
|
||||||
nokogiri (~> 1.6)
|
nokogiri (~> 1.6)
|
||||||
hamlit (2.7.2)
|
hamlit (2.7.2)
|
||||||
temple (~> 0.7.6)
|
temple (~> 0.7.6)
|
||||||
@@ -122,15 +123,17 @@ GEM
|
|||||||
hashdiff (0.3.0)
|
hashdiff (0.3.0)
|
||||||
hiredis (0.6.1)
|
hiredis (0.6.1)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (1.0.4)
|
http (2.0.3)
|
||||||
addressable (~> 2.3)
|
addressable (~> 2.3)
|
||||||
http-cookie (~> 1.0)
|
http-cookie (~> 1.0)
|
||||||
http-form_data (~> 1.0.1)
|
http-form_data (~> 1.0.1)
|
||||||
http_parser.rb (~> 0.6.0)
|
http_parser.rb (~> 0.6.0)
|
||||||
http-cookie (1.0.2)
|
http-cookie (1.0.3)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
http-form_data (1.0.1)
|
http-form_data (1.0.1)
|
||||||
http_parser.rb (0.6.0)
|
http_parser.rb (0.6.0)
|
||||||
|
httplog (0.3.2)
|
||||||
|
colorize
|
||||||
i18n (0.7.0)
|
i18n (0.7.0)
|
||||||
jbuilder (2.6.0)
|
jbuilder (2.6.0)
|
||||||
activesupport (>= 3.0.0, < 5.1)
|
activesupport (>= 3.0.0, < 5.1)
|
||||||
@@ -168,9 +171,9 @@ GEM
|
|||||||
pkg-config (~> 1.1.7)
|
pkg-config (~> 1.1.7)
|
||||||
oj (2.17.3)
|
oj (2.17.3)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostatus2 (0.3)
|
ostatus2 (0.3.1)
|
||||||
addressable (~> 2.4)
|
addressable (~> 2.4)
|
||||||
http (~> 1.0)
|
http (~> 2.0)
|
||||||
nokogiri (~> 1.6)
|
nokogiri (~> 1.6)
|
||||||
paperclip (4.3.7)
|
paperclip (4.3.7)
|
||||||
activemodel (>= 3.2.0)
|
activemodel (>= 3.2.0)
|
||||||
@@ -360,6 +363,7 @@ DEPENDENCIES
|
|||||||
hiredis
|
hiredis
|
||||||
htmlentities
|
htmlentities
|
||||||
http
|
http
|
||||||
|
httplog
|
||||||
jbuilder (~> 2.0)
|
jbuilder (~> 2.0)
|
||||||
jquery-rails
|
jquery-rails
|
||||||
letter_opener
|
letter_opener
|
||||||
|
29
README.md
29
README.md
@@ -9,6 +9,12 @@ 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 federated microblogging engine. 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]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
|
||||||
|
|
||||||
Focus of the project on a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
|
Focus of the project on a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
|
||||||
|
|
||||||
If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
|
If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
|
||||||
@@ -45,7 +51,7 @@ If you would like, you can [support the development of this project on Patreon][
|
|||||||
- `LOCAL_HTTPS` set it to `true` if HTTPS works on your website. This is used to generate canonical URLs, which is also important when generating and parsing federation-related IDs
|
- `LOCAL_HTTPS` set it to `true` if HTTPS works on your website. This is used to generate canonical URLs, which is also important when generating and parsing federation-related IDs
|
||||||
- `HUB_URL` should be the URL of the PubsubHubbub service that your instance is going to use. By default it is the open service of Superfeedr
|
- `HUB_URL` should be the URL of the PubsubHubbub service that your instance is going to use. By default it is the open service of Superfeedr
|
||||||
|
|
||||||
Consult the example configuration file, `.env.production.sample` for the full list.
|
Consult the example configuration file, `.env.production.sample` for the full list. Among other things you need to set details for the SMTP server you are going to use.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -64,14 +70,27 @@ And finally
|
|||||||
|
|
||||||
As usual, the first thing you would need to do would be to run migrations:
|
As usual, the first thing you would need to do would be to run migrations:
|
||||||
|
|
||||||
docker-compose run web rake db:migrate
|
docker-compose run --rm web rails db:migrate
|
||||||
|
|
||||||
And since the instance running in the container will be running in production mode, you need to pre-compile assets:
|
And since the instance running in the container will be running in production mode, you need to pre-compile assets:
|
||||||
|
|
||||||
docker-compose run web rake assets:precompile
|
docker-compose run --rm web rails assets:precompile
|
||||||
|
|
||||||
The container has two volumes, for the assets and for user uploads. The default docker-compose.yml maps them to the repository's `public/assets` and `public/system` directories, you may wish to put them somewhere else. Likewise, the PostgreSQL and Redis images have data containers that you may wish to map somewhere where you know how to find them and back them up.
|
The container has two volumes, for the assets and for user uploads. The default docker-compose.yml maps them to the repository's `public/assets` and `public/system` directories, you may wish to put them somewhere else. Likewise, the PostgreSQL and Redis images have data containers that you may wish to map somewhere where you know how to find them and back them up.
|
||||||
|
|
||||||
|
**Note**: The `--rm` option for docker-compose will remove the container that is created to run a one-off command after it completes. As data is stored in volumes it is not affected by that container clean-up.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- `rake mastodon:media:clear` removes uploads that have not been attached to any status after a while, you would want to run this from a periodic cronjob
|
||||||
|
- `rake mastodon:push:clear` unsubscribes from PuSH notifications for remote users that have no local followers. You may not want to actually do that, to keep a fuller footprint of the fediverse or in case your users will soon re-follow
|
||||||
|
- `rake mastodon:push:refresh` re-subscribes PuSH for expiring remote users, this should be run periodically from a cronjob and quite often as the expiration time depends on the particular hub of the remote user
|
||||||
|
- `rake mastodon:feeds:clear` removes all timelines, which forces them to be re-built on the fly next time a user tries to fetch their home/mentions timeline. Only for troubleshooting
|
||||||
|
|
||||||
|
Running any of these tasks via docker-compose would look like this:
|
||||||
|
|
||||||
|
docker-compose run --rm web rake mastodon:media:clear
|
||||||
|
|
||||||
### Updating
|
### Updating
|
||||||
|
|
||||||
This approach makes updating to the latest version a real breeze.
|
This approach makes updating to the latest version a real breeze.
|
||||||
@@ -87,3 +106,7 @@ And finally,
|
|||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
Which will re-create the updated containers, leaving databases and data as is. Depending on what files have been updated, you might need to re-run migrations and asset compilation.
|
Which will re-create the updated containers, leaving databases and data as is. Depending on what files have been updated, you might need to re-run migrations and asset compilation.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
BIN
app/assets/images/background-photo.jpeg
Normal file
BIN
app/assets/images/background-photo.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
Before Width: | Height: | Size: 1.3 MiB |
BIN
app/assets/images/logo.png
Normal file
BIN
app/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
@@ -15,6 +15,14 @@ export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
|
|||||||
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
|
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
|
||||||
export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
|
export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
|
||||||
|
|
||||||
|
export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
|
||||||
|
export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
|
||||||
|
export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
|
||||||
|
|
||||||
|
export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
|
||||||
|
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
|
||||||
|
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
|
||||||
|
|
||||||
export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
|
export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
|
||||||
export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
|
export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
|
||||||
export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL';
|
export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL';
|
||||||
@@ -36,7 +44,7 @@ export function fetchAccount(id) {
|
|||||||
|
|
||||||
dispatch(fetchAccountRequest(id));
|
dispatch(fetchAccountRequest(id));
|
||||||
|
|
||||||
axios.all([boundApi.get(`/api/accounts/${id}`), boundApi.get(`/api/accounts/relationships?id=${id}`)]).then(values => {
|
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]));
|
dispatch(fetchAccountSuccess(values[0].data, values[1].data[0]));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchAccountFail(id, error));
|
dispatch(fetchAccountFail(id, error));
|
||||||
@@ -48,7 +56,7 @@ export function fetchAccountTimeline(id) {
|
|||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(fetchAccountTimelineRequest(id));
|
dispatch(fetchAccountTimelineRequest(id));
|
||||||
|
|
||||||
api(getState).get(`/api/accounts/${id}/statuses`).then(response => {
|
api(getState).get(`/api/v1/accounts/${id}/statuses`).then(response => {
|
||||||
dispatch(fetchAccountTimelineSuccess(id, response.data));
|
dispatch(fetchAccountTimelineSuccess(id, response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchAccountTimelineFail(id, error));
|
dispatch(fetchAccountTimelineFail(id, error));
|
||||||
@@ -62,7 +70,7 @@ export function expandAccountTimeline(id) {
|
|||||||
|
|
||||||
dispatch(expandAccountTimelineRequest(id));
|
dispatch(expandAccountTimelineRequest(id));
|
||||||
|
|
||||||
api(getState).get(`/api/accounts/${id}/statuses?max_id=${lastId}`).then(response => {
|
api(getState).get(`/api/v1/accounts/${id}/statuses?max_id=${lastId}`).then(response => {
|
||||||
dispatch(expandAccountTimelineSuccess(id, response.data));
|
dispatch(expandAccountTimelineSuccess(id, response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandAccountTimelineFail(id, error));
|
dispatch(expandAccountTimelineFail(id, error));
|
||||||
@@ -97,7 +105,7 @@ export function followAccount(id) {
|
|||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(followAccountRequest(id));
|
dispatch(followAccountRequest(id));
|
||||||
|
|
||||||
api(getState).post(`/api/accounts/${id}/follow`).then(response => {
|
api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
|
||||||
dispatch(followAccountSuccess(response.data));
|
dispatch(followAccountSuccess(response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(followAccountFail(error));
|
dispatch(followAccountFail(error));
|
||||||
@@ -109,7 +117,7 @@ export function unfollowAccount(id) {
|
|||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(unfollowAccountRequest(id));
|
dispatch(unfollowAccountRequest(id));
|
||||||
|
|
||||||
api(getState).post(`/api/accounts/${id}/unfollow`).then(response => {
|
api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
|
||||||
dispatch(unfollowAccountSuccess(response.data));
|
dispatch(unfollowAccountSuccess(response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(unfollowAccountFail(error));
|
dispatch(unfollowAccountFail(error));
|
||||||
@@ -204,3 +212,69 @@ export function expandAccountTimelineFail(id, error) {
|
|||||||
error: error
|
error: error
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function blockAccount(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(blockAccountRequest(id));
|
||||||
|
|
||||||
|
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
|
||||||
|
dispatch(blockAccountSuccess(response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(blockAccountFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unblockAccount(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(unblockAccountRequest(id));
|
||||||
|
|
||||||
|
api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
|
||||||
|
dispatch(unblockAccountSuccess(response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(unblockAccountFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function blockAccountRequest(id) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_BLOCK_REQUEST,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function blockAccountSuccess(relationship) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_BLOCK_SUCCESS,
|
||||||
|
relationship: relationship
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function blockAccountFail(error) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_BLOCK_FAIL,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unblockAccountRequest(id) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_UNBLOCK_REQUEST,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unblockAccountSuccess(relationship) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_UNBLOCK_SUCCESS,
|
||||||
|
relationship: relationship
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unblockAccountFail(error) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_UNBLOCK_FAIL,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@@ -36,7 +36,7 @@ export function submitCompose() {
|
|||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(submitComposeRequest());
|
dispatch(submitComposeRequest());
|
||||||
|
|
||||||
api(getState).post('/api/statuses', {
|
api(getState).post('/api/v1/statuses', {
|
||||||
status: getState().getIn(['compose', 'text'], ''),
|
status: getState().getIn(['compose', 'text'], ''),
|
||||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
|
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
|
||||||
@@ -75,7 +75,7 @@ export function uploadCompose(files) {
|
|||||||
let data = new FormData();
|
let data = new FormData();
|
||||||
data.append('file', files[0]);
|
data.append('file', files[0]);
|
||||||
|
|
||||||
api(getState).post('/api/media', data, {
|
api(getState).post('/api/v1/media', data, {
|
||||||
onUploadProgress: function (e) {
|
onUploadProgress: function (e) {
|
||||||
dispatch(uploadComposeProgress(e.loaded, e.total));
|
dispatch(uploadComposeProgress(e.loaded, e.total));
|
||||||
}
|
}
|
||||||
|
@@ -12,14 +12,15 @@ export function changeFollow(text) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function submitFollow() {
|
export function submitFollow(router) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(submitFollowRequest());
|
dispatch(submitFollowRequest());
|
||||||
|
|
||||||
api(getState).post('/api/follows', {
|
api(getState).post('/api/v1/follows', {
|
||||||
uri: getState().getIn(['follow', 'text'])
|
uri: getState().getIn(['follow', 'text'])
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
dispatch(submitFollowSuccess(response.data));
|
dispatch(submitFollowSuccess(response.data));
|
||||||
|
router.push(`/accounts/${response.data.id}`);
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(submitFollowFail(error));
|
dispatch(submitFollowFail(error));
|
||||||
});
|
});
|
||||||
|
@@ -8,11 +8,19 @@ export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
|
|||||||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
||||||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
||||||
|
|
||||||
|
export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
|
||||||
|
export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
|
||||||
|
export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
|
||||||
|
|
||||||
|
export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
||||||
|
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||||
|
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
||||||
|
|
||||||
export function reblog(status) {
|
export function reblog(status) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(reblogRequest(status));
|
dispatch(reblogRequest(status));
|
||||||
|
|
||||||
api(getState).post(`/api/statuses/${status.get('id')}/reblog`).then(function (response) {
|
api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
|
||||||
// The reblog API method returns a new status wrapped around the original. In this case we are only
|
// The reblog API method returns a new status wrapped around the original. In this case we are only
|
||||||
// interested in how the original is modified, hence passing it skipping the wrapper
|
// interested in how the original is modified, hence passing it skipping the wrapper
|
||||||
dispatch(reblogSuccess(status, response.data.reblog));
|
dispatch(reblogSuccess(status, response.data.reblog));
|
||||||
@@ -22,6 +30,18 @@ export function reblog(status) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function unreblog(status) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(unreblogRequest(status));
|
||||||
|
|
||||||
|
api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
|
||||||
|
dispatch(unreblogSuccess(status, response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(unreblogFail(status, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function reblogRequest(status) {
|
export function reblogRequest(status) {
|
||||||
return {
|
return {
|
||||||
type: REBLOG_REQUEST,
|
type: REBLOG_REQUEST,
|
||||||
@@ -45,11 +65,34 @@ export function reblogFail(status, error) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function unreblogRequest(status) {
|
||||||
|
return {
|
||||||
|
type: UNREBLOG_REQUEST,
|
||||||
|
status: status
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unreblogSuccess(status, response) {
|
||||||
|
return {
|
||||||
|
type: UNREBLOG_SUCCESS,
|
||||||
|
status: status,
|
||||||
|
response: response
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unreblogFail(status, error) {
|
||||||
|
return {
|
||||||
|
type: UNREBLOG_FAIL,
|
||||||
|
status: status,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function favourite(status) {
|
export function favourite(status) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(favouriteRequest(status));
|
dispatch(favouriteRequest(status));
|
||||||
|
|
||||||
api(getState).post(`/api/statuses/${status.get('id')}/favourite`).then(function (response) {
|
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
|
||||||
dispatch(favouriteSuccess(status, response.data));
|
dispatch(favouriteSuccess(status, response.data));
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(favouriteFail(status, error));
|
dispatch(favouriteFail(status, error));
|
||||||
@@ -57,6 +100,18 @@ export function favourite(status) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function unfavourite(status) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(unfavouriteRequest(status));
|
||||||
|
|
||||||
|
api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
|
||||||
|
dispatch(unfavouriteSuccess(status, response.data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(unfavouriteFail(status, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function favouriteRequest(status) {
|
export function favouriteRequest(status) {
|
||||||
return {
|
return {
|
||||||
type: FAVOURITE_REQUEST,
|
type: FAVOURITE_REQUEST,
|
||||||
@@ -79,3 +134,26 @@ export function favouriteFail(status, error) {
|
|||||||
error: error
|
error: error
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function unfavouriteRequest(status) {
|
||||||
|
return {
|
||||||
|
type: UNFAVOURITE_REQUEST,
|
||||||
|
status: status
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unfavouriteSuccess(status, response) {
|
||||||
|
return {
|
||||||
|
type: UNFAVOURITE_SUCCESS,
|
||||||
|
status: status,
|
||||||
|
response: response
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function unfavouriteFail(status, error) {
|
||||||
|
return {
|
||||||
|
type: UNFAVOURITE_FAIL,
|
||||||
|
status: status,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@@ -5,6 +5,10 @@ export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
|||||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
||||||
|
|
||||||
|
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 function fetchStatusRequest(id) {
|
export function fetchStatusRequest(id) {
|
||||||
return {
|
return {
|
||||||
type: STATUS_FETCH_REQUEST,
|
type: STATUS_FETCH_REQUEST,
|
||||||
@@ -18,7 +22,7 @@ export function fetchStatus(id) {
|
|||||||
|
|
||||||
dispatch(fetchStatusRequest(id));
|
dispatch(fetchStatusRequest(id));
|
||||||
|
|
||||||
axios.all([boundApi.get(`/api/statuses/${id}`), boundApi.get(`/api/statuses/${id}/context`)]).then(values => {
|
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));
|
dispatch(fetchStatusSuccess(values[0].data, values[1].data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchStatusFail(id, error));
|
dispatch(fetchStatusFail(id, error));
|
||||||
@@ -41,3 +45,37 @@ export function fetchStatusFail(id, error) {
|
|||||||
error: error
|
error: error
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function deleteStatus(id) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(deleteStatusRequest(id));
|
||||||
|
|
||||||
|
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
|
||||||
|
dispatch(deleteStatusSuccess(id));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(deleteStatusFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deleteStatusRequest(id) {
|
||||||
|
return {
|
||||||
|
type: STATUS_DELETE_REQUEST,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deleteStatusSuccess(id) {
|
||||||
|
return {
|
||||||
|
type: STATUS_DELETE_SUCCESS,
|
||||||
|
id: id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deleteStatusFail(id, error) {
|
||||||
|
return {
|
||||||
|
type: STATUS_DELETE_FAIL,
|
||||||
|
id: id,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@@ -45,7 +45,7 @@ export function refreshTimeline(timeline) {
|
|||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(refreshTimelineRequest(timeline));
|
dispatch(refreshTimelineRequest(timeline));
|
||||||
|
|
||||||
api(getState).get(`/api/statuses/${timeline}`).then(function (response) {
|
api(getState).get(`/api/v1/statuses/${timeline}`).then(function (response) {
|
||||||
dispatch(refreshTimelineSuccess(timeline, response.data));
|
dispatch(refreshTimelineSuccess(timeline, response.data));
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(refreshTimelineFail(timeline, error));
|
dispatch(refreshTimelineFail(timeline, error));
|
||||||
@@ -67,7 +67,7 @@ export function expandTimeline(timeline) {
|
|||||||
|
|
||||||
dispatch(expandTimelineRequest(timeline));
|
dispatch(expandTimelineRequest(timeline));
|
||||||
|
|
||||||
api(getState).get(`/api/statuses/${timeline}?max_id=${lastId}`).then(response => {
|
api(getState).get(`/api/v1/statuses/${timeline}?max_id=${lastId}`).then(response => {
|
||||||
dispatch(expandTimelineSuccess(timeline, response.data));
|
dispatch(expandTimelineSuccess(timeline, response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandTimelineFail(timeline, error));
|
dispatch(expandTimelineFail(timeline, error));
|
||||||
|
@@ -19,8 +19,29 @@ const Button = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
const style = {
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
display: this.props.block ? 'block' : 'inline-block',
|
||||||
|
width: this.props.block ? '100%' : 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '10px none',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
letterSpacing: '0',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
padding: '0 16px',
|
||||||
|
height: '36px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: '36px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
textDecoration: 'none'
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ fontFamily: 'Roboto', display: this.props.block ? 'block' : 'inline-block', width: this.props.block ? '100%' : 'auto', position: 'relative', boxSizing: 'border-box', textAlign: 'center', border: '10px none', color: '#fff', fontSize: '14px', fontWeight: '500', letterSpacing: '0', textTransform: 'uppercase', padding: '0 16px', height: '36px', cursor: 'pointer', lineHeight: '36px', borderRadius: '4px', textDecoration: 'none' }}>
|
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={style}>
|
||||||
{this.props.text || this.props.children}
|
{this.props.text || this.props.children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@@ -26,8 +26,16 @@ const IconButton = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
const style = {
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: `${this.props.size}px`,
|
||||||
|
width: `${this.props.size}px`,
|
||||||
|
height: `${this.props.size}px`,
|
||||||
|
lineHeight: `${this.props.size}px`
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href='#' title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={{ display: 'inline-block', fontSize: `${this.props.size}px`, width: `${this.props.size}px`, height: `${this.props.size}px`, lineHeight: `${this.props.size}px`}}>
|
<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>
|
<i className={`fa fa-fw fa-${this.props.icon}`}></i>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@@ -22,6 +22,10 @@ const MediaGallery = React.createClass({
|
|||||||
let bottom = 'auto';
|
let bottom = 'auto';
|
||||||
let right = 'auto';
|
let right = 'auto';
|
||||||
|
|
||||||
|
if (size === 1) {
|
||||||
|
width = 100;
|
||||||
|
}
|
||||||
|
|
||||||
if (size === 4 || (size === 3 && i > 0)) {
|
if (size === 4 || (size === 3 && i > 0)) {
|
||||||
height = 50;
|
height = 50;
|
||||||
}
|
}
|
||||||
|
@@ -2,11 +2,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
import RelativeTimestamp from './relative_timestamp';
|
import RelativeTimestamp from './relative_timestamp';
|
||||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
import IconButton from './icon_button';
|
|
||||||
import DisplayName from './display_name';
|
import DisplayName from './display_name';
|
||||||
import MediaGallery from './media_gallery';
|
import MediaGallery from './media_gallery';
|
||||||
import VideoPlayer from './video_player';
|
import VideoPlayer from './video_player';
|
||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
|
import StatusActionBar from './status_action_bar';
|
||||||
|
|
||||||
const Status = React.createClass({
|
const Status = React.createClass({
|
||||||
|
|
||||||
@@ -19,23 +19,13 @@ const Status = React.createClass({
|
|||||||
wrapped: React.PropTypes.bool,
|
wrapped: React.PropTypes.bool,
|
||||||
onReply: React.PropTypes.func,
|
onReply: React.PropTypes.func,
|
||||||
onFavourite: React.PropTypes.func,
|
onFavourite: React.PropTypes.func,
|
||||||
onReblog: React.PropTypes.func
|
onReblog: React.PropTypes.func,
|
||||||
|
onDelete: React.PropTypes.func,
|
||||||
|
me: React.PropTypes.number
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
handleReplyClick () {
|
|
||||||
this.props.onReply(this.props.status);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleFavouriteClick () {
|
|
||||||
this.props.onFavourite(this.props.status);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleReblogClick () {
|
|
||||||
this.props.onReblog(this.props.status);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleClick () {
|
handleClick () {
|
||||||
const { status } = this.props;
|
const { status } = this.props;
|
||||||
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
||||||
@@ -80,7 +70,7 @@ const Status = React.createClass({
|
|||||||
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
|
<div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'pointer' }} onClick={this.handleClick}>
|
||||||
<div style={{ fontSize: '15px' }}>
|
<div style={{ fontSize: '15px' }}>
|
||||||
<div style={{ float: 'right', fontSize: '14px' }}>
|
<div style={{ float: 'right', fontSize: '14px' }}>
|
||||||
<a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }}><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')} /></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>
|
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}>
|
||||||
@@ -96,11 +86,7 @@ const Status = React.createClass({
|
|||||||
|
|
||||||
{media}
|
{media}
|
||||||
|
|
||||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
<StatusActionBar {...this.props} />
|
||||||
<div style={{ float: 'left', marginRight: '10px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div>
|
|
||||||
<div style={{ float: 'left', marginRight: '10px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div>
|
|
||||||
<div style={{ float: 'left'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,69 @@
|
|||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import IconButton from './icon_button';
|
||||||
|
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||||
|
|
||||||
|
const StatusActionBar = React.createClass({
|
||||||
|
propTypes: {
|
||||||
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
|
onReply: React.PropTypes.func,
|
||||||
|
onFavourite: React.PropTypes.func,
|
||||||
|
onReblog: React.PropTypes.func,
|
||||||
|
onDelete: React.PropTypes.func
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
handleReplyClick () {
|
||||||
|
this.props.onReply(this.props.status);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleFavouriteClick () {
|
||||||
|
this.props.onFavourite(this.props.status);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleReblogClick () {
|
||||||
|
this.props.onReblog(this.props.status);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDeleteClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.props.onDelete(this.props.status);
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { status, me } = this.props;
|
||||||
|
let menu = '';
|
||||||
|
|
||||||
|
if (status.getIn(['account', 'id']) === me) {
|
||||||
|
menu = (
|
||||||
|
<ul>
|
||||||
|
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
menu = <ul />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||||
|
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div>
|
||||||
|
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||||
|
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||||
|
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger className='icon-button' style={{ fontSize: '18px', lineHeight: '18px', width: '18px', height: '18px' }}>
|
||||||
|
<i className='fa fa-fw fa-ellipsis-h' />
|
||||||
|
</DropdownTrigger>
|
||||||
|
|
||||||
|
<DropdownContent>{menu}</DropdownContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StatusActionBar;
|
@@ -26,7 +26,7 @@ const StatusContent = React.createClass({
|
|||||||
} else {
|
} else {
|
||||||
link.setAttribute('target', '_blank');
|
link.setAttribute('target', '_blank');
|
||||||
link.setAttribute('rel', 'noopener');
|
link.setAttribute('rel', 'noopener');
|
||||||
link.addEventListener('click', this.onNormalClick);
|
link.addEventListener('click', this.onNormalClick.bind(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@@ -9,7 +9,9 @@ const StatusList = React.createClass({
|
|||||||
onReply: React.PropTypes.func,
|
onReply: React.PropTypes.func,
|
||||||
onReblog: React.PropTypes.func,
|
onReblog: React.PropTypes.func,
|
||||||
onFavourite: React.PropTypes.func,
|
onFavourite: React.PropTypes.func,
|
||||||
onScrollToBottom: React.PropTypes.func
|
onDelete: React.PropTypes.func,
|
||||||
|
onScrollToBottom: React.PropTypes.func,
|
||||||
|
me: React.PropTypes.number
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -23,11 +25,13 @@ const StatusList = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
const { statuses, onScrollToBottom, ...other } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
|
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
|
||||||
<div>
|
<div>
|
||||||
{this.props.statuses.map((status) => {
|
{statuses.map((status) => {
|
||||||
return <Status key={status.get('id')} status={status} onReply={this.props.onReply} onReblog={this.props.onReblog} onFavourite={this.props.onFavourite} />;
|
return <Status key={status.get('id')} {...other} status={status} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -4,7 +4,16 @@ import IconButton from './icon_button';
|
|||||||
|
|
||||||
const VideoPlayer = React.createClass({
|
const VideoPlayer = React.createClass({
|
||||||
propTypes: {
|
propTypes: {
|
||||||
media: ImmutablePropTypes.map.isRequired
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
|
width: React.PropTypes.number,
|
||||||
|
height: React.PropTypes.number
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultProps () {
|
||||||
|
return {
|
||||||
|
width: 196,
|
||||||
|
height: 110
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getInitialState () {
|
getInitialState () {
|
||||||
@@ -21,7 +30,7 @@ const VideoPlayer = React.createClass({
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: '196px', height: '110px', boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${this.props.width}px`, height: `${this.props.height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
|
||||||
<div style={{ position: 'absolute', top: '10px', left: '10px', opacity: '0.8' }}><IconButton title='Toggle sound' icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
|
<div style={{ 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%' }} />
|
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
|
@@ -40,11 +40,15 @@ const Mastodon = React.createClass({
|
|||||||
|
|
||||||
if (typeof App !== 'undefined') {
|
if (typeof App !== 'undefined') {
|
||||||
App.timeline = App.cable.subscriptions.create("TimelineChannel", {
|
App.timeline = App.cable.subscriptions.create("TimelineChannel", {
|
||||||
connected: function() {},
|
connected () {
|
||||||
|
|
||||||
disconnected: function() {},
|
},
|
||||||
|
|
||||||
received: function(data) {
|
disconnected () {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
received (data) {
|
||||||
switch(data.type) {
|
switch(data.type) {
|
||||||
case 'update':
|
case 'update':
|
||||||
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
|
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
|
||||||
@@ -53,6 +57,8 @@ const Mastodon = React.createClass({
|
|||||||
case 'merge':
|
case 'merge':
|
||||||
case 'unmerge':
|
case 'unmerge':
|
||||||
return store.dispatch(refreshTimeline('home'));
|
return store.dispatch(refreshTimeline('home'));
|
||||||
|
case 'block':
|
||||||
|
return store.dispatch(refreshTimeline('mentions'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -8,7 +8,7 @@ const ActionBar = React.createClass({
|
|||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
me: React.PropTypes.number.isRequired,
|
me: React.PropTypes.number.isRequired,
|
||||||
onFollow: React.PropTypes.func.isRequired,
|
onFollow: React.PropTypes.func.isRequired,
|
||||||
onUnfollow: React.PropTypes.func.isRequired
|
onBlock: React.PropTypes.func.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
@@ -16,26 +16,47 @@ const ActionBar = React.createClass({
|
|||||||
render () {
|
render () {
|
||||||
const { account, me } = this.props;
|
const { account, me } = this.props;
|
||||||
|
|
||||||
let followBack = '';
|
let infoText = '';
|
||||||
let actionButton = '';
|
let follow = '';
|
||||||
|
let buttonText = '';
|
||||||
|
let block = '';
|
||||||
|
let disabled = false;
|
||||||
|
|
||||||
if (account.get('id') === me) {
|
if (account.get('id') === me) {
|
||||||
actionButton = 'This is you!';
|
buttonText = 'This is you!';
|
||||||
|
disabled = true;
|
||||||
|
} else {
|
||||||
|
let blockText = '';
|
||||||
|
|
||||||
|
if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
buttonText = 'Blocked';
|
||||||
|
disabled = true;
|
||||||
|
blockText = 'Unblock';
|
||||||
} else {
|
} else {
|
||||||
if (account.getIn(['relationship', 'following'])) {
|
if (account.getIn(['relationship', 'following'])) {
|
||||||
actionButton = <Button text='Unfollow' onClick={this.props.onUnfollow} />
|
buttonText = 'Unfollow';
|
||||||
} else {
|
} else {
|
||||||
actionButton = <Button text='Follow' onClick={this.props.onFollow} />
|
buttonText = 'Follow';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.getIn(['relationship', 'followed_by'])) {
|
if (account.getIn(['relationship', 'followed_by'])) {
|
||||||
followBack = 'Follows you!';
|
infoText = 'Follows you!';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blockText = 'Block';
|
||||||
|
}
|
||||||
|
|
||||||
|
block = <Button text={blockText} onClick={this.props.onBlock} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account.getIn(['relationship', 'blocking'])) {
|
||||||
|
follow = <Button text={buttonText} onClick={this.props.onFollow} disabled={disabled} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px' }}>
|
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto' }}>
|
||||||
{actionButton} {followBack}
|
{follow} {block}
|
||||||
|
<span style={{ color: '#616b86', fontWeight: '500', textTransform: 'uppercase', float: 'right', display: 'block' }}>{infoText}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@@ -5,11 +5,19 @@ import {
|
|||||||
fetchAccount,
|
fetchAccount,
|
||||||
followAccount,
|
followAccount,
|
||||||
unfollowAccount,
|
unfollowAccount,
|
||||||
|
blockAccount,
|
||||||
|
unblockAccount,
|
||||||
fetchAccountTimeline,
|
fetchAccountTimeline,
|
||||||
expandAccountTimeline
|
expandAccountTimeline
|
||||||
} from '../../actions/accounts';
|
} from '../../actions/accounts';
|
||||||
|
import { deleteStatus } from '../../actions/statuses';
|
||||||
import { replyCompose } from '../../actions/compose';
|
import { replyCompose } from '../../actions/compose';
|
||||||
import { favourite, reblog } from '../../actions/interactions';
|
import {
|
||||||
|
favourite,
|
||||||
|
reblog,
|
||||||
|
unreblog,
|
||||||
|
unfavourite
|
||||||
|
} from '../../actions/interactions';
|
||||||
import Header from './components/header';
|
import Header from './components/header';
|
||||||
import {
|
import {
|
||||||
selectStatus,
|
selectStatus,
|
||||||
@@ -53,11 +61,19 @@ const Account = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleFollow () {
|
handleFollow () {
|
||||||
|
if (this.props.account.getIn(['relationship', 'following'])) {
|
||||||
|
this.props.dispatch(unfollowAccount(this.props.account.get('id')));
|
||||||
|
} else {
|
||||||
this.props.dispatch(followAccount(this.props.account.get('id')));
|
this.props.dispatch(followAccount(this.props.account.get('id')));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleUnfollow () {
|
handleBlock () {
|
||||||
this.props.dispatch(unfollowAccount(this.props.account.get('id')));
|
if (this.props.account.getIn(['relationship', 'blocking'])) {
|
||||||
|
this.props.dispatch(unblockAccount(this.props.account.get('id')));
|
||||||
|
} else {
|
||||||
|
this.props.dispatch(blockAccount(this.props.account.get('id')));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleReply (status) {
|
handleReply (status) {
|
||||||
@@ -65,11 +81,23 @@ const Account = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleReblog (status) {
|
handleReblog (status) {
|
||||||
|
if (status.get('reblogged')) {
|
||||||
|
this.props.dispatch(unreblog(status));
|
||||||
|
} else {
|
||||||
this.props.dispatch(reblog(status));
|
this.props.dispatch(reblog(status));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleFavourite (status) {
|
handleFavourite (status) {
|
||||||
|
if (status.get('favourited')) {
|
||||||
|
this.props.dispatch(unfavourite(status));
|
||||||
|
} else {
|
||||||
this.props.dispatch(favourite(status));
|
this.props.dispatch(favourite(status));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDelete (status) {
|
||||||
|
this.props.dispatch(deleteStatus(status.get('id')));
|
||||||
},
|
},
|
||||||
|
|
||||||
handleScrollToBottom () {
|
handleScrollToBottom () {
|
||||||
@@ -86,8 +114,8 @@ const Account = React.createClass({
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
|
||||||
<Header account={account} />
|
<Header account={account} />
|
||||||
<ActionBar account={account} me={me} onFollow={this.handleFollow} onUnfollow={this.handleUnfollow} />
|
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
|
||||||
<StatusList statuses={statuses} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
|
<StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,30 @@
|
|||||||
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||||
|
import IconButton from '../../../components/icon_button';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
|
const ActionBar = React.createClass({
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
|
onReply: React.PropTypes.func.isRequired,
|
||||||
|
onReblog: React.PropTypes.func.isRequired,
|
||||||
|
onFavourite: React.PropTypes.func.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { status } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
|
||||||
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div>
|
||||||
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={() => this.props.onReblog(status)} /></div>
|
||||||
|
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={() => this.props.onFavourite(status)} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ActionBar;
|
@@ -0,0 +1,63 @@
|
|||||||
|
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 StatusContent from '../../../components/status_content';
|
||||||
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
|
import VideoPlayer from '../../../components/video_player';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const DetailedStatus = React.createClass({
|
||||||
|
|
||||||
|
contextTypes: {
|
||||||
|
router: React.PropTypes.object
|
||||||
|
},
|
||||||
|
|
||||||
|
propTypes: {
|
||||||
|
status: ImmutablePropTypes.map.isRequired
|
||||||
|
},
|
||||||
|
|
||||||
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
|
handleAccountClick (e) {
|
||||||
|
if (e.button === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
|
||||||
|
let media = '';
|
||||||
|
|
||||||
|
if (status.get('media_attachments').size > 0) {
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#2f3441', padding: '14px 10px' }} className='detailed-status'>
|
||||||
|
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
|
||||||
|
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div>
|
||||||
|
<DisplayName account={status.get('account')} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<StatusContent status={status} />
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DetailedStatus;
|
@@ -4,6 +4,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import { fetchStatus } from '../../actions/statuses';
|
import { fetchStatus } from '../../actions/statuses';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
import EmbeddedStatus from '../../components/status';
|
import EmbeddedStatus from '../../components/status';
|
||||||
|
import DetailedStatus from './components/detailed_status';
|
||||||
|
import ActionBar from './components/action_bar';
|
||||||
import { favourite, reblog } from '../../actions/interactions';
|
import { favourite, reblog } from '../../actions/interactions';
|
||||||
import { replyCompose } from '../../actions/compose';
|
import { replyCompose } from '../../actions/compose';
|
||||||
import { selectStatus } from '../../reducers/timelines';
|
import { selectStatus } from '../../reducers/timelines';
|
||||||
@@ -69,7 +71,8 @@ const Status = React.createClass({
|
|||||||
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable'>
|
||||||
<div>{this.renderChildren(ancestors)}</div>
|
<div>{this.renderChildren(ancestors)}</div>
|
||||||
|
|
||||||
<EmbeddedStatus status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />
|
<DetailedStatus status={status} />
|
||||||
|
<ActionBar status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} />
|
||||||
|
|
||||||
<div>{this.renderChildren(descendants)}</div>
|
<div>{this.renderChildren(descendants)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -3,15 +3,18 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|||||||
const CharacterCounter = React.createClass({
|
const CharacterCounter = React.createClass({
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
text: React.PropTypes.string.isRequired
|
text: React.PropTypes.string.isRequired,
|
||||||
|
max: React.PropTypes.number.isRequired
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [PureRenderMixin],
|
mixins: [PureRenderMixin],
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
const diff = this.props.max - this.props.text.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span style={{ fontSize: '16px', cursor: 'default' }}>
|
<span style={{ fontSize: '16px', cursor: 'default' }}>
|
||||||
{this.props.text.length}
|
{diff}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -10,6 +10,7 @@ const ComposeForm = React.createClass({
|
|||||||
propTypes: {
|
propTypes: {
|
||||||
text: React.PropTypes.string.isRequired,
|
text: React.PropTypes.string.isRequired,
|
||||||
is_submitting: React.PropTypes.bool,
|
is_submitting: React.PropTypes.bool,
|
||||||
|
is_uploading: React.PropTypes.bool,
|
||||||
in_reply_to: ImmutablePropTypes.map,
|
in_reply_to: ImmutablePropTypes.map,
|
||||||
onChange: React.PropTypes.func.isRequired,
|
onChange: React.PropTypes.func.isRequired,
|
||||||
onSubmit: React.PropTypes.func.isRequired,
|
onSubmit: React.PropTypes.func.isRequired,
|
||||||
@@ -40,6 +41,7 @@ const ComposeForm = React.createClass({
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
let replyArea = '';
|
let replyArea = '';
|
||||||
|
const disabled = this.props.is_submitting || this.props.is_uploading;
|
||||||
|
|
||||||
if (this.props.in_reply_to) {
|
if (this.props.in_reply_to) {
|
||||||
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
|
||||||
@@ -49,11 +51,11 @@ const ComposeForm = React.createClass({
|
|||||||
<div style={{ padding: '10px' }}>
|
<div style={{ padding: '10px' }}>
|
||||||
{replyArea}
|
{replyArea}
|
||||||
|
|
||||||
<textarea ref='textarea' disabled={this.props.is_submitting} 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' }} />
|
<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' }} />
|
||||||
|
|
||||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||||
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
|
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>
|
||||||
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter text={this.props.text} /></div>
|
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -3,6 +3,10 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|||||||
|
|
||||||
const FollowForm = React.createClass({
|
const FollowForm = React.createClass({
|
||||||
|
|
||||||
|
contextTypes: {
|
||||||
|
router: React.PropTypes.object
|
||||||
|
},
|
||||||
|
|
||||||
propTypes: {
|
propTypes: {
|
||||||
text: React.PropTypes.string.isRequired,
|
text: React.PropTypes.string.isRequired,
|
||||||
is_submitting: React.PropTypes.bool,
|
is_submitting: React.PropTypes.bool,
|
||||||
@@ -18,12 +22,12 @@ const FollowForm = React.createClass({
|
|||||||
|
|
||||||
handleKeyUp (e) {
|
handleKeyUp (e) {
|
||||||
if (e.keyCode === 13) {
|
if (e.keyCode === 13) {
|
||||||
this.props.onSubmit();
|
this.handleSubmit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleSubmit () {
|
handleSubmit () {
|
||||||
this.props.onSubmit();
|
this.props.onSubmit(this.context.router);
|
||||||
},
|
},
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
|
@@ -7,6 +7,7 @@ const mapStateToProps = function (state, props) {
|
|||||||
return {
|
return {
|
||||||
text: state.getIn(['compose', 'text']),
|
text: state.getIn(['compose', 'text']),
|
||||||
is_submitting: state.getIn(['compose', 'is_submitting']),
|
is_submitting: state.getIn(['compose', 'is_submitting']),
|
||||||
|
is_uploading: state.getIn(['compose', 'is_uploading']),
|
||||||
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to']))
|
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to']))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -15,8 +15,8 @@ const mapDispatchToProps = function (dispatch) {
|
|||||||
dispatch(changeFollow(text));
|
dispatch(changeFollow(text));
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit: function () {
|
onSubmit: function (router) {
|
||||||
dispatch(submitFollow());
|
dispatch(submitFollow(router));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -1,32 +1,51 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import StatusList from '../../../components/status_list';
|
import StatusList from '../../../components/status_list';
|
||||||
import { replyCompose } from '../../../actions/compose';
|
import { replyCompose } from '../../../actions/compose';
|
||||||
import { reblog, favourite } from '../../../actions/interactions';
|
import {
|
||||||
|
reblog,
|
||||||
|
favourite,
|
||||||
|
unreblog,
|
||||||
|
unfavourite
|
||||||
|
} from '../../../actions/interactions';
|
||||||
import { expandTimeline } from '../../../actions/timelines';
|
import { expandTimeline } from '../../../actions/timelines';
|
||||||
import { selectStatus } from '../../../reducers/timelines';
|
import { selectStatus } from '../../../reducers/timelines';
|
||||||
|
import { deleteStatus } from '../../../actions/statuses';
|
||||||
|
|
||||||
const mapStateToProps = function (state, props) {
|
const mapStateToProps = function (state, props) {
|
||||||
return {
|
return {
|
||||||
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id))
|
statuses: state.getIn(['timelines', props.type]).map(id => selectStatus(state, id)),
|
||||||
|
me: state.getIn(['timelines', 'me'])
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = function (dispatch, props) {
|
const mapDispatchToProps = function (dispatch, props) {
|
||||||
return {
|
return {
|
||||||
onReply: function (status) {
|
onReply (status) {
|
||||||
dispatch(replyCompose(status));
|
dispatch(replyCompose(status));
|
||||||
},
|
},
|
||||||
|
|
||||||
onFavourite: function (status) {
|
onFavourite (status) {
|
||||||
|
if (status.get('favourited')) {
|
||||||
|
dispatch(unfavourite(status));
|
||||||
|
} else {
|
||||||
dispatch(favourite(status));
|
dispatch(favourite(status));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onReblog: function (status) {
|
onReblog (status) {
|
||||||
|
if (status.get('reblogged')) {
|
||||||
|
dispatch(unreblog(status));
|
||||||
|
} else {
|
||||||
dispatch(reblog(status));
|
dispatch(reblog(status));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onScrollToBottom: function () {
|
onScrollToBottom () {
|
||||||
dispatch(expandTimeline(props.type));
|
dispatch(expandTimeline(props.type));
|
||||||
|
},
|
||||||
|
|
||||||
|
onDelete (status) {
|
||||||
|
dispatch(deleteStatus(status.get('id')));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -12,6 +12,7 @@ import {
|
|||||||
COMPOSE_UPLOAD_PROGRESS
|
COMPOSE_UPLOAD_PROGRESS
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
|
import { ACCOUNT_SET_SELF } from '../actions/accounts';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.Map({
|
const initialState = Immutable.Map({
|
||||||
@@ -20,11 +21,19 @@ const initialState = Immutable.Map({
|
|||||||
is_submitting: false,
|
is_submitting: false,
|
||||||
is_uploading: false,
|
is_uploading: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
media_attachments: Immutable.List([])
|
media_attachments: Immutable.List([]),
|
||||||
|
me: null
|
||||||
});
|
});
|
||||||
|
|
||||||
function statusToTextMentions(status) {
|
function statusToTextMentions(state, status) {
|
||||||
return Immutable.OrderedSet([`@${status.getIn(['account', 'acct'])} `]).union(status.get('mentions').map(mention => `@${mention.get('acct')} `)).join('');
|
let set = Immutable.OrderedSet([]);
|
||||||
|
let me = state.get('me');
|
||||||
|
|
||||||
|
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('');
|
||||||
};
|
};
|
||||||
|
|
||||||
function clearAll(state) {
|
function clearAll(state) {
|
||||||
@@ -60,7 +69,7 @@ export default function compose(state = initialState, action) {
|
|||||||
case COMPOSE_REPLY:
|
case COMPOSE_REPLY:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('in_reply_to', action.status.get('id'));
|
map.set('in_reply_to', action.status.get('id'));
|
||||||
map.set('text', statusToTextMentions(action.status));
|
map.set('text', statusToTextMentions(state, action.status));
|
||||||
});
|
});
|
||||||
case COMPOSE_REPLY_CANCEL:
|
case COMPOSE_REPLY_CANCEL:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
@@ -89,6 +98,8 @@ export default function compose(state = initialState, action) {
|
|||||||
} else {
|
} else {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
case ACCOUNT_SET_SELF:
|
||||||
|
return state.set('me', action.account.id);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
import { COMPOSE_SUBMIT_FAIL, COMPOSE_UPLOAD_FAIL } from '../actions/compose';
|
import { COMPOSE_SUBMIT_FAIL, COMPOSE_UPLOAD_FAIL } from '../actions/compose';
|
||||||
import { FOLLOW_SUBMIT_FAIL } from '../actions/follow';
|
import { FOLLOW_SUBMIT_FAIL } from '../actions/follow';
|
||||||
import { REBLOG_FAIL, FAVOURITE_FAIL } from '../actions/interactions';
|
import {
|
||||||
|
REBLOG_FAIL,
|
||||||
|
UNREBLOG_FAIL,
|
||||||
|
FAVOURITE_FAIL,
|
||||||
|
UNFAVOURITE_FAIL
|
||||||
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
TIMELINE_REFRESH_FAIL,
|
TIMELINE_REFRESH_FAIL,
|
||||||
TIMELINE_EXPAND_FAIL
|
TIMELINE_EXPAND_FAIL
|
||||||
@@ -13,7 +18,10 @@ import {
|
|||||||
ACCOUNT_TIMELINE_FETCH_FAIL,
|
ACCOUNT_TIMELINE_FETCH_FAIL,
|
||||||
ACCOUNT_TIMELINE_EXPAND_FAIL
|
ACCOUNT_TIMELINE_EXPAND_FAIL
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import { STATUS_FETCH_FAIL } from '../actions/statuses';
|
import {
|
||||||
|
STATUS_FETCH_FAIL,
|
||||||
|
STATUS_DELETE_FAIL
|
||||||
|
} from '../actions/statuses';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
const initialState = Immutable.List();
|
const initialState = Immutable.List();
|
||||||
@@ -51,6 +59,9 @@ export default function notifications(state = initialState, action) {
|
|||||||
case ACCOUNT_TIMELINE_FETCH_FAIL:
|
case ACCOUNT_TIMELINE_FETCH_FAIL:
|
||||||
case ACCOUNT_TIMELINE_EXPAND_FAIL:
|
case ACCOUNT_TIMELINE_EXPAND_FAIL:
|
||||||
case STATUS_FETCH_FAIL:
|
case STATUS_FETCH_FAIL:
|
||||||
|
case STATUS_DELETE_FAIL:
|
||||||
|
case UNREBLOG_FAIL:
|
||||||
|
case UNFAVOURITE_FAIL:
|
||||||
return notificationFromError(state, action.error);
|
return notificationFromError(state, action.error);
|
||||||
case NOTIFICATION_DISMISS:
|
case NOTIFICATION_DISMISS:
|
||||||
return state.filterNot(item => item.get('key') === action.notification.key);
|
return state.filterNot(item => item.get('key') === action.notification.key);
|
||||||
|
@@ -6,17 +6,24 @@ import {
|
|||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
import {
|
import {
|
||||||
REBLOG_SUCCESS,
|
REBLOG_SUCCESS,
|
||||||
FAVOURITE_SUCCESS
|
UNREBLOG_SUCCESS,
|
||||||
|
FAVOURITE_SUCCESS,
|
||||||
|
UNFAVOURITE_SUCCESS
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_SET_SELF,
|
ACCOUNT_SET_SELF,
|
||||||
ACCOUNT_FETCH_SUCCESS,
|
ACCOUNT_FETCH_SUCCESS,
|
||||||
ACCOUNT_FOLLOW_SUCCESS,
|
ACCOUNT_FOLLOW_SUCCESS,
|
||||||
ACCOUNT_UNFOLLOW_SUCCESS,
|
ACCOUNT_UNFOLLOW_SUCCESS,
|
||||||
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
|
ACCOUNT_UNBLOCK_SUCCESS,
|
||||||
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
ACCOUNT_TIMELINE_FETCH_SUCCESS,
|
||||||
ACCOUNT_TIMELINE_EXPAND_SUCCESS
|
ACCOUNT_TIMELINE_EXPAND_SUCCESS
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
import { STATUS_FETCH_SUCCESS } from '../actions/statuses';
|
import {
|
||||||
|
STATUS_FETCH_SUCCESS,
|
||||||
|
STATUS_DELETE_SUCCESS
|
||||||
|
} from '../actions/statuses';
|
||||||
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
import { FOLLOW_SUBMIT_SUCCESS } from '../actions/follow';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
|
|
||||||
@@ -142,10 +149,28 @@ function updateTimeline(state, timeline, status) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function deleteStatus(state, id) {
|
function deleteStatus(state, id) {
|
||||||
|
const status = state.getIn(['statuses', id]);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove references from timelines
|
||||||
['home', 'mentions'].forEach(function (timeline) {
|
['home', 'mentions'].forEach(function (timeline) {
|
||||||
state = state.update(timeline, list => list.filterNot(item => item === id));
|
state = state.update(timeline, list => list.filterNot(item => item === id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove references from account timelines
|
||||||
|
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id));
|
||||||
|
|
||||||
|
// Remove reblogs of deleted status
|
||||||
|
const references = state.get('statuses').filter(item => item.get('reblog') === id);
|
||||||
|
|
||||||
|
references.forEach(referencingId => {
|
||||||
|
state = deleteStatus(state, referencingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove normalized status
|
||||||
return state.deleteIn(['statuses', id]);
|
return state.deleteIn(['statuses', id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,9 +219,12 @@ export default function timelines(state = initialState, action) {
|
|||||||
case TIMELINE_UPDATE:
|
case TIMELINE_UPDATE:
|
||||||
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status));
|
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status));
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
|
case STATUS_DELETE_SUCCESS:
|
||||||
return deleteStatus(state, action.id);
|
return deleteStatus(state, action.id);
|
||||||
case REBLOG_SUCCESS:
|
case REBLOG_SUCCESS:
|
||||||
case FAVOURITE_SUCCESS:
|
case FAVOURITE_SUCCESS:
|
||||||
|
case UNREBLOG_SUCCESS:
|
||||||
|
case UNFAVOURITE_SUCCESS:
|
||||||
return normalizeStatus(state, Immutable.fromJS(action.response));
|
return normalizeStatus(state, Immutable.fromJS(action.response));
|
||||||
case ACCOUNT_SET_SELF:
|
case ACCOUNT_SET_SELF:
|
||||||
return setSelf(state, Immutable.fromJS(action.account));
|
return setSelf(state, Immutable.fromJS(action.account));
|
||||||
@@ -205,6 +233,8 @@ export default function timelines(state = initialState, action) {
|
|||||||
return normalizeAccount(state, Immutable.fromJS(action.account), Immutable.fromJS(action.relationship));
|
return normalizeAccount(state, Immutable.fromJS(action.account), Immutable.fromJS(action.relationship));
|
||||||
case ACCOUNT_FOLLOW_SUCCESS:
|
case ACCOUNT_FOLLOW_SUCCESS:
|
||||||
case ACCOUNT_UNFOLLOW_SUCCESS:
|
case ACCOUNT_UNFOLLOW_SUCCESS:
|
||||||
|
case ACCOUNT_UNBLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
return normalizeRelationship(state, Immutable.fromJS(action.relationship));
|
return normalizeRelationship(state, Immutable.fromJS(action.relationship));
|
||||||
case STATUS_FETCH_SUCCESS:
|
case STATUS_FETCH_SUCCESS:
|
||||||
return normalizeContext(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants));
|
return normalizeContext(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants));
|
||||||
|
47
app/assets/stylesheets/about.scss
Normal file
47
app/assets/stylesheets/about.scss
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
@import url(https://fonts.googleapis.com/css?family=Montserrat);
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Judson);
|
||||||
|
|
||||||
|
.about-body {
|
||||||
|
.wrapper {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #9baec8;
|
||||||
|
padding-top: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font: 46px/52px 'Roboto', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #2b90d9;
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin-bottom: -5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font: 20px/28px 'Judson', sans-serif;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 7px 7px 5px 7px;
|
||||||
|
background: #9baec8;
|
||||||
|
color: #282c37;
|
||||||
|
font: 16px/16px 'Montserrat', sans-serif;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 360px) {
|
||||||
|
.wrapper {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -8,6 +8,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
@media screen and (max-width: 700px) {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: block;
|
display: block;
|
||||||
@@ -58,6 +63,13 @@
|
|||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-counters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
order: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counter {
|
.counter {
|
||||||
@@ -124,6 +136,26 @@
|
|||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
color: #d9e1e8;
|
color: #d9e1e8;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 360px) {
|
||||||
|
.details {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +213,18 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
color: lighten(#282c37, 10%);
|
color: lighten(#282c37, 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 360px) {
|
||||||
|
padding: 30px 20px;
|
||||||
|
|
||||||
|
a, .current, .next_page, .previous_page, .gap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next_page, .previous_page {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.accounts-grid {
|
.accounts-grid {
|
||||||
@@ -192,6 +236,11 @@
|
|||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media screen and (max-width: 700px) {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.account-grid-card {
|
.account-grid-card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 335px;
|
width: 335px;
|
||||||
|
@@ -76,6 +76,10 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 360px) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-holder {
|
.app-holder {
|
||||||
@@ -90,13 +94,23 @@ body {
|
|||||||
width: 700px;
|
width: 700px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
|
|
||||||
|
@media screen and (max-width: 700px) {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-container {
|
.logo-container {
|
||||||
width: 400px;
|
max-width: 400px;
|
||||||
margin: 100px auto;
|
margin: 100px auto;
|
||||||
|
margin-bottom: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
|
@media screen and (max-width: 360px) {
|
||||||
|
margin: 30px auto;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -104,10 +118,29 @@ body {
|
|||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
margin: 20px auto;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: all 0.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
img {
|
||||||
|
opacity: 1;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
@@ -120,14 +153,31 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
width: 400px;
|
max-width: 400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type=text], input[type=email], input[type=password] {
|
.file-field {
|
||||||
|
padding: 15px 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
width: 100px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=file] {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text], input[type=email], input[type=password], textarea {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 2px solid #9baec8;
|
border-bottom: 2px solid #9baec8;
|
||||||
@@ -137,6 +187,7 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
|
font-family: 'Roboto';
|
||||||
|
|
||||||
&:invalid {
|
&:invalid {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -226,6 +277,13 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flash-message {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.form-footer {
|
.form-footer {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -278,6 +336,11 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: 'Roboto Mono', monospace;
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.powered-by {
|
.powered-by {
|
||||||
@@ -296,67 +359,7 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.turbolinks-progress-bar {
|
|
||||||
background-color: #2b90d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mastodon {
|
|
||||||
$head: #282c37;
|
|
||||||
$tusk: #d9e1e8;
|
|
||||||
$backdrop: #2b90d9;
|
|
||||||
$highlight: 18%;
|
|
||||||
|
|
||||||
.mastodon-backdrop {
|
|
||||||
fill: $backdrop;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mastodon-ear {
|
|
||||||
fill: lighten($head, 5%);
|
|
||||||
|
|
||||||
&:hover, &.highlight {
|
|
||||||
fill: lighten($head, $highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#mastodon-head-backdrop {
|
|
||||||
fill: darken($head, 5%);
|
|
||||||
|
|
||||||
&:hover, &.highlight {
|
|
||||||
fill: darken($head, 5%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#mastodon-nose, #mastodon-cheek, #mastodon-forehead, #mastodon-backhead {
|
|
||||||
fill: $head;
|
|
||||||
|
|
||||||
&:hover, &.highlight {
|
|
||||||
fill: lighten($head, $highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#mastodon-tusk-front {
|
|
||||||
fill: lighten($tusk, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mastodon-tusk-back {
|
|
||||||
fill: $tusk;
|
|
||||||
|
|
||||||
&:hover, &.highlight {
|
|
||||||
fill: lighten($tusk, $highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mastodon-shape {
|
|
||||||
transition: all 0.8s ease;
|
|
||||||
stroke: transparent;
|
|
||||||
stroke-width: 0px;
|
|
||||||
|
|
||||||
&:hover, &.highlight {
|
|
||||||
transition-duration: 0.2s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@import 'accounts';
|
@import 'accounts';
|
||||||
@import 'stream_entries';
|
@import 'stream_entries';
|
||||||
@import 'components'
|
@import 'components';
|
||||||
|
@import 'about';
|
||||||
|
@@ -1,5 +1,22 @@
|
|||||||
.button {
|
.button {
|
||||||
background-color: #2b90d9;
|
background-color: #2b90d9;
|
||||||
|
font-family: 'Roboto';
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
border: 10px none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 36px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #489fde;
|
background-color: #489fde;
|
||||||
@@ -7,6 +24,7 @@
|
|||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background-color: #9baec8;
|
background-color: #9baec8;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.button-secondary {
|
&.button-secondary {
|
||||||
@@ -82,6 +100,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailed-status {
|
||||||
|
.status__content {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.reply-indicator__content {
|
.reply-indicator__content {
|
||||||
color: #282c37;
|
color: #282c37;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -91,7 +116,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__display-name, .status__relative-time {
|
.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +126,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__display-name, .reply-indicator__display-name {
|
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name {
|
||||||
&:hover {
|
&:hover {
|
||||||
strong {
|
strong {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -109,7 +134,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__relative-time {
|
.detailed-status__display-name {
|
||||||
|
color: #d9e1e8;
|
||||||
|
line-height: 24px;
|
||||||
|
|
||||||
|
strong, span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status__relative-time, .detailed-status__datetime {
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
@@ -118,3 +157,54 @@
|
|||||||
.transparent-background {
|
.transparent-background {
|
||||||
background: image-url('void.png');
|
background: image-url('void.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown__content {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown--active .dropdown__content {
|
||||||
|
display: block;
|
||||||
|
z-index: 9999;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0 4.5px 7.8px 4.5px;
|
||||||
|
border-color: transparent transparent #d9e1e8 transparent;
|
||||||
|
top: -7px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
background: #d9e1e8;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-size: 13px;
|
||||||
|
display: block;
|
||||||
|
padding: 6px 16px;
|
||||||
|
width: 120px;
|
||||||
|
text-decoration: none;
|
||||||
|
background: #d9e1e8;
|
||||||
|
color: #282c37;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2b90d9;
|
||||||
|
color: #d9e1e8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -56,12 +56,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.activity-stream-embedded {
|
@media screen and (max-width: 700px) {
|
||||||
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
||||||
.entry {
|
.entry {
|
||||||
|
&:last-child {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 360px) {
|
||||||
|
.avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry__container {
|
.entry__container {
|
||||||
@@ -76,7 +93,7 @@
|
|||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 56px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,27 +113,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header__right {
|
.header__right {
|
||||||
.counter-btn {
|
|
||||||
color: #d9e1e8;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 10px;
|
|
||||||
cursor: default;
|
|
||||||
|
|
||||||
.counter-number {
|
|
||||||
font-weight: 500;
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.reblogged {
|
|
||||||
color: #2b90d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.favourited {
|
|
||||||
color: #df405a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
@@ -205,6 +202,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
|
margin-bottom: 21px;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -213,7 +211,7 @@
|
|||||||
height: 100px;
|
height: 100px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-bottom: 25px;
|
margin-bottom: 4px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: block;
|
display: block;
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
|
|
||||||
module ApplicationCable
|
module ApplicationCable
|
||||||
class Channel < ActionCable::Channel::Base
|
class Channel < ActionCable::Channel::Base
|
||||||
end
|
end
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
|
|
||||||
module ApplicationCable
|
module ApplicationCable
|
||||||
class Connection < ActionCable::Connection::Base
|
class Connection < ActionCable::Connection::Base
|
||||||
identified_by :current_user
|
identified_by :current_user
|
||||||
@@ -10,13 +9,12 @@ module ApplicationCable
|
|||||||
protected
|
protected
|
||||||
|
|
||||||
def find_verified_user
|
def find_verified_user
|
||||||
|
catch :warden do
|
||||||
verified_user = env['warden'].user
|
verified_user = env['warden'].user
|
||||||
|
return verified_user if verified_user
|
||||||
|
end
|
||||||
|
|
||||||
if verified_user
|
|
||||||
verified_user
|
|
||||||
else
|
|
||||||
reject_unauthorized_connection
|
reject_unauthorized_connection
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
|
|
||||||
class TimelineChannel < ApplicationCable::Channel
|
class TimelineChannel < ApplicationCable::Channel
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from "timeline:#{current_user.account_id}"
|
stream_from "timeline:#{current_user.account_id}"
|
||||||
|
5
app/controllers/about_controller.rb
Normal file
5
app/controllers/about_controller.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class AboutController < ApplicationController
|
||||||
|
def index
|
||||||
|
@body_classes = 'about-body'
|
||||||
|
end
|
||||||
|
end
|
@@ -8,15 +8,6 @@ class AccountsController < ApplicationController
|
|||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html do
|
format.html do
|
||||||
@statuses = @account.statuses.order('id desc').with_includes.with_counters.paginate(page: params[:page], per_page: 10)
|
@statuses = @account.statuses.order('id desc').with_includes.with_counters.paginate(page: params[:page], per_page: 10)
|
||||||
|
|
||||||
if user_signed_in?
|
|
||||||
status_ids = @statuses.collect { |s| [s.id, s.reblog_of_id] }.flatten.uniq
|
|
||||||
@favourited = Status.favourites_map(status_ids, current_user.account_id)
|
|
||||||
@reblogged = Status.reblogs_map(status_ids, current_user.account_id)
|
|
||||||
else
|
|
||||||
@favourited = {}
|
|
||||||
@reblogged = {}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
format.atom do
|
format.atom do
|
||||||
|
@@ -1,13 +0,0 @@
|
|||||||
class Api::FollowsController < ApiController
|
|
||||||
before_action :doorkeeper_authorize!
|
|
||||||
respond_to :json
|
|
||||||
|
|
||||||
def create
|
|
||||||
if params[:uri].blank?
|
|
||||||
raise ActiveRecord::RecordNotFound
|
|
||||||
end
|
|
||||||
|
|
||||||
@account = FollowService.new.(current_user.account, params[:uri]).try(:target_account)
|
|
||||||
render action: :show
|
|
||||||
end
|
|
||||||
end
|
|
@@ -3,7 +3,7 @@ class Api::SalmonController < ApiController
|
|||||||
respond_to :txt
|
respond_to :txt
|
||||||
|
|
||||||
def update
|
def update
|
||||||
ProcessInteractionService.new.(request.body.read, @account)
|
ProcessInteractionService.new.call(request.body.read, @account)
|
||||||
head 201
|
head 201
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@@ -1,37 +0,0 @@
|
|||||||
class Api::StatusesController < ApiController
|
|
||||||
before_action :doorkeeper_authorize!
|
|
||||||
respond_to :json
|
|
||||||
|
|
||||||
def show
|
|
||||||
@status = Status.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def context
|
|
||||||
@status = Status.find(params[:id])
|
|
||||||
@ancestors = @status.ancestors
|
|
||||||
@descendants = @status.descendants
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@status = PostStatusService.new.(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), params[:media_ids])
|
|
||||||
render action: :show
|
|
||||||
end
|
|
||||||
|
|
||||||
def reblog
|
|
||||||
@status = ReblogService.new.(current_user.account, Status.find(params[:id])).reload
|
|
||||||
render action: :show
|
|
||||||
end
|
|
||||||
|
|
||||||
def favourite
|
|
||||||
@status = FavouriteService.new.(current_user.account, Status.find(params[:id])).status.reload
|
|
||||||
render action: :show
|
|
||||||
end
|
|
||||||
|
|
||||||
def home
|
|
||||||
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id]).to_a
|
|
||||||
end
|
|
||||||
|
|
||||||
def mentions
|
|
||||||
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id]).to_a
|
|
||||||
end
|
|
||||||
end
|
|
@@ -4,7 +4,7 @@ class Api::SubscriptionsController < ApiController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
if @account.subscription(api_subscription_url(@account.id)).valid?(params['hub.topic'])
|
if @account.subscription(api_subscription_url(@account.id)).valid?(params['hub.topic'])
|
||||||
@account.update(subscription_expires_at: Time.now + ((params['hub.lease_seconds'] || 86400).to_i).seconds)
|
@account.update(subscription_expires_at: Time.now.utc + (params['hub.lease_seconds'] || 86_400).to_i.seconds)
|
||||||
render plain: HTMLEntities.new.encode(params['hub.challenge']), status: 200
|
render plain: HTMLEntities.new.encode(params['hub.challenge']), status: 200
|
||||||
else
|
else
|
||||||
head 404
|
head 404
|
||||||
@@ -15,7 +15,7 @@ class Api::SubscriptionsController < ApiController
|
|||||||
body = request.body.read
|
body = request.body.read
|
||||||
|
|
||||||
if @account.subscription(api_subscription_url(@account.id)).verify(body, request.headers['HTTP_X_HUB_SIGNATURE'])
|
if @account.subscription(api_subscription_url(@account.id)).verify(body, request.headers['HTTP_X_HUB_SIGNATURE'])
|
||||||
ProcessFeedService.new.(body, @account)
|
ProcessFeedService.new.call(body, @account)
|
||||||
head 201
|
head 201
|
||||||
else
|
else
|
||||||
head 202
|
head 202
|
||||||
|
@@ -1,11 +1,16 @@
|
|||||||
class Api::AccountsController < ApiController
|
class Api::V1::AccountsController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action :doorkeeper_authorize!
|
||||||
before_action :set_account
|
before_action :set_account, except: :verify_credentials
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
def show
|
def show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def verify_credentials
|
||||||
|
@account = current_user.account
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
|
||||||
def following
|
def following
|
||||||
@following = @account.following
|
@following = @account.following
|
||||||
end
|
end
|
||||||
@@ -15,27 +20,39 @@ class Api::AccountsController < ApiController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def statuses
|
def statuses
|
||||||
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id] || nil).to_a
|
@statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(20, params[:max_id], params[:since_id]).to_a
|
||||||
end
|
end
|
||||||
|
|
||||||
def follow
|
def follow
|
||||||
@follow = FollowService.new.(current_user.account, @account.acct)
|
FollowService.new.call(current_user.account, @account.acct)
|
||||||
|
set_relationship
|
||||||
|
render action: :relationship
|
||||||
|
end
|
||||||
|
|
||||||
|
def block
|
||||||
|
BlockService.new.call(current_user.account, @account)
|
||||||
set_relationship
|
set_relationship
|
||||||
render action: :relationship
|
render action: :relationship
|
||||||
end
|
end
|
||||||
|
|
||||||
def unfollow
|
def unfollow
|
||||||
@unfollow = UnfollowService.new.(current_user.account, @account)
|
UnfollowService.new.call(current_user.account, @account)
|
||||||
|
set_relationship
|
||||||
|
render action: :relationship
|
||||||
|
end
|
||||||
|
|
||||||
|
def unblock
|
||||||
|
UnblockService.new.call(current_user.account, @account)
|
||||||
set_relationship
|
set_relationship
|
||||||
render action: :relationship
|
render action: :relationship
|
||||||
end
|
end
|
||||||
|
|
||||||
def relationships
|
def relationships
|
||||||
ids = params[:id].is_a?(Enumerable) ? params[:id].map { |id| id.to_i } : [params[:id].to_i]
|
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
|
||||||
@accounts = Account.find(ids)
|
@accounts = Account.find(ids)
|
||||||
@following = Account.following_map(ids, current_user.account_id)
|
@following = Account.following_map(ids, current_user.account_id)
|
||||||
@followed_by = Account.followed_by_map(ids, current_user.account_id)
|
@followed_by = Account.followed_by_map(ids, current_user.account_id)
|
||||||
@blocking = {}
|
@blocking = Account.blocking_map(ids, current_user.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -47,6 +64,6 @@ class Api::AccountsController < ApiController
|
|||||||
def set_relationship
|
def set_relationship
|
||||||
@following = Account.following_map([@account.id], current_user.account_id)
|
@following = Account.following_map([@account.id], current_user.account_id)
|
||||||
@followed_by = Account.followed_by_map([@account.id], current_user.account_id)
|
@followed_by = Account.followed_by_map([@account.id], current_user.account_id)
|
||||||
@blocking = {}
|
@blocking = Account.blocking_map([@account.id], current_user.account_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
7
app/controllers/api/v1/apps_controller.rb
Normal file
7
app/controllers/api/v1/apps_controller.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class Api::V1::AppsController < ApiController
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
def create
|
||||||
|
@app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris])
|
||||||
|
end
|
||||||
|
end
|
11
app/controllers/api/v1/follows_controller.rb
Normal file
11
app/controllers/api/v1/follows_controller.rb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class Api::V1::FollowsController < ApiController
|
||||||
|
before_action :doorkeeper_authorize!
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
def create
|
||||||
|
raise ActiveRecord::RecordNotFound if params[:uri].blank?
|
||||||
|
|
||||||
|
@account = FollowService.new.call(current_user.account, params[:uri]).try(:target_account)
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
end
|
@@ -1,4 +1,4 @@
|
|||||||
class Api::MediaController < ApiController
|
class Api::V1::MediaController < ApiController
|
||||||
before_action :doorkeeper_authorize!
|
before_action :doorkeeper_authorize!
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
54
app/controllers/api/v1/statuses_controller.rb
Normal file
54
app/controllers/api/v1/statuses_controller.rb
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
class Api::V1::StatusesController < ApiController
|
||||||
|
before_action :doorkeeper_authorize!
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
def show
|
||||||
|
@status = Status.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def context
|
||||||
|
@status = Status.find(params[:id])
|
||||||
|
@ancestors = @status.ancestors
|
||||||
|
@descendants = @status.descendants
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), params[:media_ids])
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@status = Status.where(account_id: current_user.account).find(params[:id])
|
||||||
|
RemoveStatusService.new.call(@status)
|
||||||
|
render_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
def reblog
|
||||||
|
@status = ReblogService.new.call(current_user.account, Status.find(params[:id])).reload
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def unreblog
|
||||||
|
RemoveStatusService.new.call(Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!)
|
||||||
|
@status = Status.find(params[:id])
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def favourite
|
||||||
|
@status = FavouriteService.new.call(current_user.account, Status.find(params[:id])).status.reload
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def unfavourite
|
||||||
|
@status = UnfavouriteService.new.call(current_user.account, Status.find(params[:id])).status.reload
|
||||||
|
render action: :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def home
|
||||||
|
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def mentions
|
||||||
|
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
||||||
|
end
|
||||||
|
end
|
@@ -2,8 +2,8 @@ class ApiController < ApplicationController
|
|||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid do
|
rescue_from ActiveRecord::RecordInvalid do |e|
|
||||||
render json: { error: 'Record invalid' }, status: 422
|
render json: { error: e.to_s }, status: 422
|
||||||
end
|
end
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordNotFound do
|
rescue_from ActiveRecord::RecordNotFound do
|
||||||
@@ -18,6 +18,10 @@ class ApiController < ApplicationController
|
|||||||
render json: { error: 'Remote data could not be fetched' }, status: 503
|
render json: { error: 'Remote data could not be fetched' }, status: 503
|
||||||
end
|
end
|
||||||
|
|
||||||
|
rescue_from OpenSSL::SSL::SSLError do
|
||||||
|
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def current_resource_owner
|
def current_resource_owner
|
||||||
@@ -27,4 +31,8 @@ class ApiController < ApplicationController
|
|||||||
def current_user
|
def current_user
|
||||||
super || current_resource_owner
|
super || current_resource_owner
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_empty
|
||||||
|
render json: {}, status: 200
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@@ -10,8 +10,16 @@ class ApplicationController < ActionController::Base
|
|||||||
rescue_from ActionController::RoutingError, with: :not_found
|
rescue_from ActionController::RoutingError, with: :not_found
|
||||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||||
|
|
||||||
|
before_action :store_current_location, unless: :devise_controller?
|
||||||
|
|
||||||
def raise_not_found
|
def raise_not_found
|
||||||
raise ActionController::RoutingError.new("No route matches #{params[:unmatched_route]}")
|
raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def store_current_location
|
||||||
|
store_location_for(:user, request.url)
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
@@ -22,6 +30,12 @@ class ApplicationController < ActionController::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def gone
|
||||||
|
respond_to do |format|
|
||||||
|
format.any { head 410 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def current_account
|
def current_account
|
||||||
current_user.try(:account)
|
current_user.try(:account)
|
||||||
end
|
end
|
||||||
|
3
app/controllers/auth/confirmations_controller.rb
Normal file
3
app/controllers/auth/confirmations_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
class Auth::ConfirmationsController < Devise::ConfirmationsController
|
||||||
|
layout 'auth'
|
||||||
|
end
|
@@ -1,13 +1,13 @@
|
|||||||
class Auth::RegistrationsController < Devise::RegistrationsController
|
class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
layout 'auth'
|
layout 'auth'
|
||||||
|
|
||||||
before_filter :configure_sign_up_params, only: [:create]
|
before_action :configure_sign_up_params, only: [:create]
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def build_resource(hash = nil)
|
def build_resource(hash = nil)
|
||||||
super(hash)
|
super(hash)
|
||||||
self.resource.build_account if self.resource.account.nil?
|
resource.build_account if resource.account.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def configure_sign_up_params
|
def configure_sign_up_params
|
||||||
|
@@ -8,4 +8,16 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
remember_me(resource)
|
remember_me(resource)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def after_sign_in_path_for(_resource)
|
||||||
|
last_url = stored_location_for(:user)
|
||||||
|
|
||||||
|
if [about_path].include?(last_url)
|
||||||
|
root_path
|
||||||
|
else
|
||||||
|
last_url || root_path
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@@ -10,6 +10,10 @@ class HomeController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def authenticate_user!
|
||||||
|
redirect_to about_path unless user_signed_in?
|
||||||
|
end
|
||||||
|
|
||||||
def find_or_create_access_token
|
def find_or_create_access_token
|
||||||
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, nil, Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
|
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, nil, Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
|
||||||
end
|
end
|
||||||
|
@@ -1,18 +0,0 @@
|
|||||||
class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
|
|
||||||
before_action :authenticate_user!
|
|
||||||
|
|
||||||
def index
|
|
||||||
@applications = current_user.oauth_applications
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@application = Doorkeeper::Application.new(application_params)
|
|
||||||
@application.owner = current_user
|
|
||||||
|
|
||||||
if @application.save
|
|
||||||
redirect_to oauth_application_url(@application)
|
|
||||||
else
|
|
||||||
render :new
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@@ -1,4 +1,6 @@
|
|||||||
class SettingsController < ApplicationController
|
class SettingsController < ApplicationController
|
||||||
|
layout 'auth'
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :set_account
|
before_action :set_account
|
||||||
|
|
||||||
|
@@ -8,22 +8,16 @@ class StreamEntriesController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
@type = @stream_entry.activity_type.downcase
|
@type = @stream_entry.activity_type.downcase
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
return gone if @stream_entry.activity.nil?
|
||||||
|
|
||||||
if @stream_entry.activity_type == 'Status'
|
if @stream_entry.activity_type == 'Status'
|
||||||
@ancestors = @stream_entry.activity.ancestors
|
@ancestors = @stream_entry.activity.ancestors
|
||||||
@descendants = @stream_entry.activity.descendants
|
@descendants = @stream_entry.activity.descendants
|
||||||
|
|
||||||
if user_signed_in?
|
|
||||||
status_ids = [@stream_entry.activity_id] + @ancestors.map { |s| s.id } + @descendants.map { |s| s.id }
|
|
||||||
@favourited = Status.favourites_map(status_ids, current_user.account_id)
|
|
||||||
@reblogged = Status.reblogs_map(status_ids, current_user.account_id)
|
|
||||||
else
|
|
||||||
@favourited = {}
|
|
||||||
@reblogged = {}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html
|
|
||||||
format.atom
|
format.atom
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -31,9 +31,9 @@ class XrdController < ApplicationController
|
|||||||
|
|
||||||
def pem_to_magic_key(public_key)
|
def pem_to_magic_key(public_key)
|
||||||
modulus, exponent = [public_key.n, public_key.e].map do |component|
|
modulus, exponent = [public_key.n, public_key.e].map do |component|
|
||||||
result = ""
|
result = ''
|
||||||
|
|
||||||
until component == 0 do
|
until component.zero?
|
||||||
result << [component % 256].pack('C')
|
result << [component % 256].pack('C')
|
||||||
component >>= 8
|
component >>= 8
|
||||||
end
|
end
|
||||||
@@ -41,7 +41,7 @@ class XrdController < ApplicationController
|
|||||||
result.reverse!
|
result.reverse!
|
||||||
end
|
end
|
||||||
|
|
||||||
(["RSA"] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.')
|
(['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.')
|
||||||
end
|
end
|
||||||
|
|
||||||
def resource_param
|
def resource_param
|
||||||
|
2
app/helpers/about_helper.rb
Normal file
2
app/helpers/about_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module AboutHelper
|
||||||
|
end
|
@@ -1,5 +1,10 @@
|
|||||||
module AccountsHelper
|
module AccountsHelper
|
||||||
def pagination_options
|
def pagination_options
|
||||||
{ previous_label: "#{fa_icon('chevron-left')} Prev".html_safe, next_label: "Next #{fa_icon('chevron-right')}".html_safe, inner_window: 2 }
|
{
|
||||||
|
previous_label: "#{fa_icon('chevron-left')} Prev".html_safe,
|
||||||
|
next_label: "Next #{fa_icon('chevron-right')}".html_safe,
|
||||||
|
inner_window: 1,
|
||||||
|
outer_window: 0
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
module Api::AccountsHelper
|
|
||||||
end
|
|
@@ -1,2 +0,0 @@
|
|||||||
module Api::FollowsHelper
|
|
||||||
end
|
|
@@ -1,2 +0,0 @@
|
|||||||
module Api::MediaHelper
|
|
||||||
end
|
|
@@ -1,2 +0,0 @@
|
|||||||
module Api::SalmonHelper
|
|
||||||
end
|
|
@@ -1,2 +0,0 @@
|
|||||||
module Api::StatusesHelper
|
|
||||||
end
|
|
@@ -1,2 +0,0 @@
|
|||||||
module Api::SubscriptionsHelper
|
|
||||||
end
|
|
@@ -200,6 +200,6 @@ module AtomBuilderHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def single_link_avatar(xml, account, size, px)
|
def single_link_avatar(xml, account, size, px)
|
||||||
xml.link('rel' => 'avatar', 'type' => account.avatar_content_type, 'media:width' => px, 'media:height' =>px, 'href' => full_asset_url(account.avatar.url(size, false)))
|
xml.link('rel' => 'avatar', 'type' => account.avatar_content_type, 'media:width' => px, 'media:height' => px, 'href' => full_asset_url(account.avatar.url(size, false)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -3,11 +3,11 @@ module HomeHelper
|
|||||||
{
|
{
|
||||||
token: @token,
|
token: @token,
|
||||||
|
|
||||||
account: render(file: 'api/accounts/show', locals: { account: current_user.account }, formats: :json),
|
account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json),
|
||||||
|
|
||||||
timelines: {
|
timelines: {
|
||||||
home: render(file: 'api/statuses/home', locals: { statuses: @home }, formats: :json),
|
home: render(file: 'api/v1/statuses/home', locals: { statuses: @home }, formats: :json),
|
||||||
mentions: render(file: 'api/statuses/mentions', locals: { statuses: @mentions }, formats: :json)
|
mentions: render(file: 'api/v1/statuses/mentions', locals: { statuses: @mentions }, formats: :json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
module Oauth::ApplicationsHelper
|
|
||||||
end
|
|
@@ -17,15 +17,15 @@ module StreamEntriesHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def relative_time(date)
|
def relative_time(date)
|
||||||
date < 5.days.ago ? date.strftime("%d.%m.%Y") : "#{time_ago_in_words(date)} ago"
|
date < 5.days.ago ? date.strftime('%d.%m.%Y') : "#{time_ago_in_words(date)} ago"
|
||||||
end
|
end
|
||||||
|
|
||||||
def reblogged_by_me_class(status)
|
def reblogged_by_me_class(status)
|
||||||
user_signed_in? && @reblogged.has_key?(status.id) ? 'reblogged' : ''
|
user_signed_in? && @reblogged.key?(status.id) ? 'reblogged' : ''
|
||||||
end
|
end
|
||||||
|
|
||||||
def favourited_by_me_class(status)
|
def favourited_by_me_class(status)
|
||||||
user_signed_in? && @favourited.has_key?(status.id) ? 'favourited' : ''
|
user_signed_in? && @favourited.key?(status.id) ? 'favourited' : ''
|
||||||
end
|
end
|
||||||
|
|
||||||
def proper_status(status)
|
def proper_status(status)
|
||||||
|
@@ -9,13 +9,16 @@ class FeedManager
|
|||||||
"feed:#{type}:#{id}"
|
"feed:#{type}:#{id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_status?(status, follower)
|
def filter?(timeline_type, status, receiver)
|
||||||
replied_to_user = status.reply? ? status.thread.account : nil
|
if timeline_type == :home
|
||||||
(status.reply? && !(follower.id = replied_to_user.id || follower.following?(replied_to_user)))
|
filter_from_home?(status, receiver)
|
||||||
|
else
|
||||||
|
filter_from_mentions?(status, receiver)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def push(timeline_type, account, status)
|
def push(timeline_type, account, status)
|
||||||
redis.zadd(key(timeline_type, account.id), status.id, status.id)
|
redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
|
||||||
trim(timeline_type, account.id)
|
trim(timeline_type, account.id)
|
||||||
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
|
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
|
||||||
end
|
end
|
||||||
@@ -36,6 +39,16 @@ class FeedManager
|
|||||||
$redis
|
$redis
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
|
||||||
|
def filter_from_home?(status, receiver)
|
||||||
|
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)))
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_from_mentions?(status, receiver)
|
||||||
|
receiver.blocking?(status.account) || (status.reblog? && receiver.blocking?(status.reblog.account))
|
||||||
|
end
|
||||||
|
|
||||||
def inline_render(target_account, status)
|
def inline_render(target_account, status)
|
||||||
rabl_scope = Class.new do
|
rabl_scope = Class.new do
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
@@ -53,6 +66,6 @@ class FeedManager
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Rabl::Renderer.new('api/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render
|
Rabl::Renderer.new('api/v1/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -35,7 +35,7 @@ class Formatter
|
|||||||
def link_mentions(html, mentions)
|
def link_mentions(html, mentions)
|
||||||
html.gsub(Account::MENTION_RE) do |match|
|
html.gsub(Account::MENTION_RE) do |match|
|
||||||
acct = Account::MENTION_RE.match(match)[1]
|
acct = Account::MENTION_RE.match(match)[1]
|
||||||
mention = mentions.find { |item| item.account.acct.eql?(acct) }
|
mention = mentions.find { |item| item.account.acct.casecmp(acct).zero? }
|
||||||
|
|
||||||
mention.nil? ? match : mention_html(match, mention.account)
|
mention.nil? ? match : mention_html(match, mention.account)
|
||||||
end
|
end
|
||||||
|
@@ -2,11 +2,11 @@ class Account < ApplicationRecord
|
|||||||
include Targetable
|
include Targetable
|
||||||
|
|
||||||
MENTION_RE = /(?:^|\s|\.|>)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
|
MENTION_RE = /(?:^|\s|\.|>)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
|
||||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif']
|
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||||
|
|
||||||
# Local users
|
# Local users
|
||||||
has_one :user, inverse_of: :account
|
has_one :user, inverse_of: :account
|
||||||
validates :username, presence: true, format: { with: /\A[a-z0-9_]+\z/i, message: 'only letters, numbers and underscores' }, uniqueness: { scope: :domain, case_sensitive: false }, if: 'local?'
|
validates :username, presence: true, format: { with: /\A[a-z0-9_]+\z/i, message: 'only letters, numbers and underscores' }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: 'local?'
|
||||||
validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?'
|
validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?'
|
||||||
|
|
||||||
# Avatar upload
|
# Avatar upload
|
||||||
@@ -33,8 +33,12 @@ class Account < ApplicationRecord
|
|||||||
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
|
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
|
||||||
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
|
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
|
||||||
|
|
||||||
has_many :following, through: :active_relationships, source: :target_account
|
has_many :following, -> { order('follows.created_at desc') }, through: :active_relationships, source: :target_account
|
||||||
has_many :followers, through: :passive_relationships, source: :account
|
has_many :followers, -> { order('follows.created_at 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 :media_attachments, dependent: :destroy
|
has_many :media_attachments, dependent: :destroy
|
||||||
|
|
||||||
@@ -45,28 +49,41 @@ class Account < ApplicationRecord
|
|||||||
scope :expiring, -> (time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
|
scope :expiring, -> (time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
|
||||||
|
|
||||||
def follow!(other_account)
|
def follow!(other_account)
|
||||||
self.active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def block!(other_account)
|
||||||
|
block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def unfollow!(other_account)
|
def unfollow!(other_account)
|
||||||
follow = self.active_relationships.find_by(target_account: other_account)
|
follow = active_relationships.find_by(target_account: other_account)
|
||||||
follow.destroy unless follow.nil?
|
follow.destroy unless follow.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def unblock!(other_account)
|
||||||
|
block = block_relationships.find_by(target_account: other_account)
|
||||||
|
block.destroy unless block.nil?
|
||||||
|
end
|
||||||
|
|
||||||
def following?(other_account)
|
def following?(other_account)
|
||||||
following.include?(other_account)
|
following.include?(other_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def blocking?(other_account)
|
||||||
|
blocking.include?(other_account)
|
||||||
|
end
|
||||||
|
|
||||||
def local?
|
def local?
|
||||||
self.domain.nil?
|
domain.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def acct
|
def acct
|
||||||
local? ? self.username : "#{self.username}@#{self.domain}"
|
local? ? username : "#{username}@#{domain}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscribed?
|
def subscribed?
|
||||||
!self.subscription_expires_at.nil?
|
!subscription_expires_at.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def favourited?(status)
|
def favourited?(status)
|
||||||
@@ -78,11 +95,11 @@ class Account < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def keypair
|
def keypair
|
||||||
self.private_key.nil? ? OpenSSL::PKey::RSA.new(self.public_key) : OpenSSL::PKey::RSA.new(self.private_key)
|
private_key.nil? ? OpenSSL::PKey::RSA.new(public_key) : OpenSSL::PKey::RSA.new(private_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscription(webhook_url)
|
def subscription(webhook_url)
|
||||||
OStatus2::Subscription.new(self.remote_url, secret: self.secret, lease_seconds: 86400 * 30, webhook: webhook_url, hub: self.hub_url)
|
OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 86_400 * 30, webhook: webhook_url, hub: hub_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
def ping!(atom_url, hubs)
|
def ping!(atom_url, hubs)
|
||||||
@@ -91,10 +108,7 @@ class Account < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def avatar_remote_url=(url)
|
def avatar_remote_url=(url)
|
||||||
unless self[:avatar_remote_url] == url
|
self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
|
||||||
self.avatar = URI.parse(url)
|
|
||||||
end
|
|
||||||
|
|
||||||
self[:avatar_remote_url] = url
|
self[:avatar_remote_url] = url
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -103,26 +117,25 @@ class Account < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def to_param
|
def to_param
|
||||||
self.username
|
username
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_local!(username)
|
def self.find_local!(username)
|
||||||
self.find_remote!(username, nil)
|
find_remote!(username, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_remote!(username, domain)
|
def self.find_remote!(username, domain)
|
||||||
table = self.arel_table
|
where(arel_table[:username].matches(username)).where(domain: domain).take!
|
||||||
self.where(table[:username].matches(username)).where(domain: domain).take!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_local(username)
|
def self.find_local(username)
|
||||||
self.find_local!(username)
|
find_local!(username)
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.find_remote(username, domain)
|
def self.find_remote(username, domain)
|
||||||
self.find_remote!(username, domain)
|
find_remote!(username, domain)
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -135,6 +148,10 @@ class Account < ApplicationRecord
|
|||||||
Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h
|
Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.blocking_map(target_account_ids, account_id)
|
||||||
|
Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h
|
||||||
|
end
|
||||||
|
|
||||||
before_create do
|
before_create do
|
||||||
if local?
|
if local?
|
||||||
keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048)
|
keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048)
|
||||||
|
7
app/models/block.rb
Normal file
7
app/models/block.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class Block < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :target_account, class_name: 'Account'
|
||||||
|
|
||||||
|
validates :account, :target_account, presence: true
|
||||||
|
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||||
|
end
|
@@ -2,6 +2,11 @@ module Paginable
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
scope :paginate_by_max_id, -> (limit, max_id) { order('id desc').limit(limit).where(max_id.nil? ? '1=1' : ['id < ?', max_id]) }
|
def self.paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
||||||
|
query = order('id desc').limit(limit)
|
||||||
|
query = query.where('id < ?', max_id) unless max_id.blank?
|
||||||
|
query = query.where('id > ?', since_id) unless since_id.blank?
|
||||||
|
query
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -25,7 +25,7 @@ module Streamable
|
|||||||
end
|
end
|
||||||
|
|
||||||
after_create do
|
after_create do
|
||||||
self.account.stream_entries.create!(activity: self) if self.account.local?
|
account.stream_entries.create!(activity: self) if account.local?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -11,15 +11,13 @@ class Favourite < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def title
|
def title
|
||||||
"#{self.account.acct} favourited a status by #{self.status.account.acct}"
|
"#{account.acct} favourited a status by #{status.account.acct}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def object_type
|
delegate :object_type, to: :target
|
||||||
target.object_type
|
|
||||||
end
|
|
||||||
|
|
||||||
def thread
|
def thread
|
||||||
self.status
|
status
|
||||||
end
|
end
|
||||||
|
|
||||||
def target
|
def target
|
||||||
|
@@ -4,16 +4,17 @@ class Feed
|
|||||||
@account = account
|
@account = account
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(limit, max_id = nil)
|
def get(limit, max_id = nil, since_id = nil)
|
||||||
max_id = '+inf' if max_id.nil?
|
max_id = '+inf' if max_id.blank?
|
||||||
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", '-inf', limit: [0, limit])
|
since_id = '-inf' if since_id.blank?
|
||||||
status_map = Hash.new
|
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).collect(&:last).map(&:to_i)
|
||||||
|
status_map = {}
|
||||||
|
|
||||||
# If we're after most recent items and none are there, we need to precompute the feed
|
# If we're after most recent items and none are there, we need to precompute the feed
|
||||||
if unhydrated.empty? && max_id == '+inf'
|
if unhydrated.empty? && max_id == '+inf'
|
||||||
PrecomputeFeedService.new.(@type, @account, limit)
|
PrecomputeFeedService.new.call(@type, @account, limit)
|
||||||
else
|
else
|
||||||
Status.where(id: unhydrated).with_includes.with_counters.each { |status| status_map[status.id.to_s] = status }
|
Status.where(id: unhydrated).with_includes.with_counters.each { |status| status_map[status.id] = status }
|
||||||
unhydrated.map { |id| status_map[id] }.compact
|
unhydrated.map { |id| status_map[id] }.compact
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -8,11 +8,11 @@ class Follow < ApplicationRecord
|
|||||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||||
|
|
||||||
def verb
|
def verb
|
||||||
self.destroyed? ? :unfollow : :follow
|
destroyed? ? :unfollow : :follow
|
||||||
end
|
end
|
||||||
|
|
||||||
def target
|
def target
|
||||||
self.target_account
|
target_account
|
||||||
end
|
end
|
||||||
|
|
||||||
def object_type
|
def object_type
|
||||||
@@ -20,6 +20,6 @@ class Follow < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def title
|
def title
|
||||||
self.destroyed? ? "#{self.account.acct} is no longer following #{self.target_account.acct}" : "#{self.account.acct} started following #{self.target_account.acct}"
|
destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -1,18 +1,18 @@
|
|||||||
class MediaAttachment < ApplicationRecord
|
class MediaAttachment < ApplicationRecord
|
||||||
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif']
|
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||||
VIDEO_MIME_TYPES = ['video/webm']
|
VIDEO_MIME_TYPES = ['video/webm'].freeze
|
||||||
|
|
||||||
belongs_to :account, inverse_of: :media_attachments
|
belongs_to :account, inverse_of: :media_attachments
|
||||||
belongs_to :status, inverse_of: :media_attachments
|
belongs_to :status, inverse_of: :media_attachments
|
||||||
|
|
||||||
has_attached_file :file, styles: lambda { |f| f.instance.image? ? { small: '510x680>' } : { small: { convert_options: { output: { vf: 'scale="min(510\, iw):min(680\, ih)":force_original_aspect_ratio=decrease' } }, format: 'png', time: 1 } } }, processors: lambda { |f| f.video? ? [:transcoder] : [:thumbnail] }
|
has_attached_file :file, styles: -> (f) { f.instance.image? ? { small: '510x680>' } : { small: { convert_options: { output: { vf: 'scale="min(510\, iw):min(680\, ih)":force_original_aspect_ratio=decrease' } }, format: 'png', time: 1 } } }, processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] }
|
||||||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
|
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
|
||||||
validates_attachment_size :file, less_than: 4.megabytes
|
validates_attachment_size :file, less_than: 4.megabytes
|
||||||
|
|
||||||
validates :account, presence: true
|
validates :account, presence: true
|
||||||
|
|
||||||
def local?
|
def local?
|
||||||
self.remote_url.blank?
|
remote_url.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
def file_remote_url=(url)
|
def file_remote_url=(url)
|
||||||
|
@@ -15,21 +15,22 @@ class Status < ApplicationRecord
|
|||||||
|
|
||||||
validates :account, presence: true
|
validates :account, presence: true
|
||||||
validates :uri, uniqueness: true, unless: 'local?'
|
validates :uri, uniqueness: true, unless: 'local?'
|
||||||
validates :text, presence: true, if: Proc.new { |s| s.local? && !s.reblog? }
|
validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? }
|
||||||
|
validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?'
|
||||||
|
|
||||||
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
|
scope :with_counters, -> { select('statuses.*, (select count(r.id) from statuses as r where r.reblog_of_id = statuses.id) as reblogs_count, (select count(f.id) from favourites as f where f.status_id = statuses.id) as favourites_count') }
|
||||||
scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
|
scope :with_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
|
||||||
|
|
||||||
def local?
|
def local?
|
||||||
self.uri.nil?
|
uri.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def reblog?
|
def reblog?
|
||||||
!self.reblog_of_id.nil?
|
!reblog_of_id.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def reply?
|
def reply?
|
||||||
!self.in_reply_to_id.nil?
|
!in_reply_to_id.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def verb
|
def verb
|
||||||
@@ -41,11 +42,11 @@ class Status < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def content
|
def content
|
||||||
reblog? ? self.reblog.text : self.text
|
reblog? ? reblog.text : text
|
||||||
end
|
end
|
||||||
|
|
||||||
def target
|
def target
|
||||||
self.reblog
|
reblog
|
||||||
end
|
end
|
||||||
|
|
||||||
def title
|
def title
|
||||||
@@ -53,33 +54,33 @@ class Status < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reblogs_count
|
def reblogs_count
|
||||||
self.attributes['reblogs_count'] || self.reblogs.count
|
attributes['reblogs_count'] || reblogs.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def favourites_count
|
def favourites_count
|
||||||
self.attributes['favourites_count'] || self.favourites.count
|
attributes['favourites_count'] || favourites.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def ancestors
|
def ancestors
|
||||||
ids = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, in_reply_to_id, path) AS (SELECT id, in_reply_to_id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id FROM search_tree JOIN statuses ON statuses.id = search_tree.in_reply_to_id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path DESC', self.id]) - [self]).pluck(:id)
|
ids = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, in_reply_to_id, path) AS (SELECT id, in_reply_to_id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id FROM search_tree JOIN statuses ON statuses.id = search_tree.in_reply_to_id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path DESC', id]) - [self]).pluck(:id)
|
||||||
statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id)
|
statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id)
|
||||||
|
|
||||||
ids.map { |id| statuses[id].first }
|
ids.map { |id| statuses[id].first }
|
||||||
end
|
end
|
||||||
|
|
||||||
def descendants
|
def descendants
|
||||||
ids = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, path) AS (SELECT id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, path || statuses.id FROM search_tree JOIN statuses ON statuses.in_reply_to_id = search_tree.id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path', self.id]) - [self]).pluck(:id)
|
ids = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, path) AS (SELECT id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, path || statuses.id FROM search_tree JOIN statuses ON statuses.in_reply_to_id = search_tree.id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path', id]) - [self]).pluck(:id)
|
||||||
statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id)
|
statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id)
|
||||||
|
|
||||||
ids.map { |id| statuses[id].first }
|
ids.map { |id| statuses[id].first }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.as_home_timeline(account)
|
def self.as_home_timeline(account)
|
||||||
self.where(account: [account] + account.following).with_includes.with_counters
|
where(account: [account] + account.following).with_includes.with_counters
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.as_mentions_timeline(account)
|
def self.as_mentions_timeline(account)
|
||||||
self.where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
|
where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.favourites_map(status_ids, account_id)
|
def self.favourites_map(status_ids, account_id)
|
||||||
@@ -87,10 +88,10 @@ class Status < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.reblogs_map(status_ids, account_id)
|
def self.reblogs_map(status_ids, account_id)
|
||||||
self.where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
|
where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
before_validation do
|
before_validation do
|
||||||
self.text.strip!
|
text.strip!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -10,16 +10,16 @@ class StreamEntry < ApplicationRecord
|
|||||||
|
|
||||||
validates :account, :activity, presence: true
|
validates :account, :activity, presence: true
|
||||||
|
|
||||||
STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]]
|
STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
|
||||||
|
|
||||||
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) }
|
scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) }
|
||||||
|
|
||||||
def object_type
|
def object_type
|
||||||
orphaned? ? :activity : (targeted? ? :activity : self.activity.object_type)
|
orphaned? ? :activity : (targeted? ? :activity : activity.object_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
def verb
|
def verb
|
||||||
orphaned? ? :delete : self.activity.verb
|
orphaned? ? :delete : activity.verb
|
||||||
end
|
end
|
||||||
|
|
||||||
def targeted?
|
def targeted?
|
||||||
@@ -27,15 +27,15 @@ class StreamEntry < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def target
|
def target
|
||||||
orphaned? ? nil : self.activity.target
|
orphaned? ? nil : activity.target
|
||||||
end
|
end
|
||||||
|
|
||||||
def title
|
def title
|
||||||
orphaned? ? nil : self.activity.title
|
orphaned? ? nil : activity.title
|
||||||
end
|
end
|
||||||
|
|
||||||
def content
|
def content
|
||||||
orphaned? ? nil : self.activity.content
|
orphaned? ? nil : activity.content
|
||||||
end
|
end
|
||||||
|
|
||||||
def threaded?
|
def threaded?
|
||||||
@@ -43,20 +43,20 @@ class StreamEntry < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def thread
|
def thread
|
||||||
orphaned? ? nil : self.activity.thread
|
orphaned? ? nil : activity.thread
|
||||||
end
|
end
|
||||||
|
|
||||||
def mentions
|
def mentions
|
||||||
self.activity.respond_to?(:mentions) ? self.activity.mentions.map { |x| x.account } : []
|
activity.respond_to?(:mentions) ? activity.mentions.map(&:account) : []
|
||||||
end
|
end
|
||||||
|
|
||||||
def activity
|
def activity
|
||||||
self.send(self.activity_type.downcase.to_sym)
|
send(activity_type.downcase.to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def orphaned?
|
def orphaned?
|
||||||
self.activity.nil?
|
activity.nil?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -1,14 +1,12 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable
|
devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable
|
||||||
|
|
||||||
belongs_to :account, inverse_of: :user
|
belongs_to :account, inverse_of: :user
|
||||||
accepts_nested_attributes_for :account
|
accepts_nested_attributes_for :account
|
||||||
|
|
||||||
validates :account, presence: true
|
validates :account, presence: true
|
||||||
|
|
||||||
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner
|
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') }
|
||||||
def admin?
|
scope :admins, -> { where(admin: true) }
|
||||||
self.admin
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
25
app/services/block_service.rb
Normal file
25
app/services/block_service.rb
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class BlockService < BaseService
|
||||||
|
def call(account, target_account)
|
||||||
|
return if account.id == target_account.id
|
||||||
|
|
||||||
|
UnfollowService.new.call(account, target_account) if account.following?(target_account)
|
||||||
|
account.block!(target_account)
|
||||||
|
clear_mentions(account, target_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def clear_mentions(account, target_account)
|
||||||
|
timeline_key = FeedManager.instance.key(:mentions, account.id)
|
||||||
|
|
||||||
|
target_account.statuses.select('id').find_each do |status|
|
||||||
|
redis.zrem(timeline_key, status.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
FeedManager.instance.broadcast(account.id, type: 'block', id: target_account.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
$redis
|
||||||
|
end
|
||||||
|
end
|
@@ -15,7 +15,7 @@ class FanOutOnWriteService < BaseService
|
|||||||
|
|
||||||
def deliver_to_followers(status)
|
def deliver_to_followers(status)
|
||||||
status.account.followers.each do |follower|
|
status.account.followers.each do |follower|
|
||||||
next if !follower.local? || FeedManager.instance.filter_status?(status, follower)
|
next if !follower.local? || FeedManager.instance.filter?(:home, status, follower)
|
||||||
FeedManager.instance.push(:home, follower, status)
|
FeedManager.instance.push(:home, follower, status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -23,7 +23,7 @@ class FanOutOnWriteService < BaseService
|
|||||||
def deliver_to_mentioned(status)
|
def deliver_to_mentioned(status)
|
||||||
status.mentions.each do |mention|
|
status.mentions.each do |mention|
|
||||||
mentioned_account = mention.account
|
mentioned_account = mention.account
|
||||||
next unless mentioned_account.local?
|
next if !mentioned_account.local? || mentioned_account.id == status.account_id || FeedManager.instance.filter?(:mentions, status, mentioned_account)
|
||||||
FeedManager.instance.push(:mentions, mentioned_account, status)
|
FeedManager.instance.push(:mentions, mentioned_account, status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -5,10 +5,10 @@ class FavouriteService < BaseService
|
|||||||
# @return [Favourite]
|
# @return [Favourite]
|
||||||
def call(account, status)
|
def call(account, status)
|
||||||
favourite = Favourite.create!(account: account, status: status)
|
favourite = Favourite.create!(account: account, status: status)
|
||||||
account.ping!(account_url(account, format: 'atom'), [Rails.configuration.x.hub_url])
|
HubPingWorker.perform_async(account.id)
|
||||||
|
|
||||||
if status.local?
|
if status.local?
|
||||||
NotificationMailer.favourite(status, account).deliver_later
|
NotificationMailer.favourite(status, account).deliver_later unless status.account.blocking?(account)
|
||||||
else
|
else
|
||||||
NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id)
|
NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id)
|
||||||
end
|
end
|
||||||
|
49
app/services/fetch_atom_service.rb
Normal file
49
app/services/fetch_atom_service.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
class FetchAtomService < BaseService
|
||||||
|
def call(url)
|
||||||
|
response = http_client.head(url)
|
||||||
|
|
||||||
|
Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
|
||||||
|
return nil if response.code != 200
|
||||||
|
|
||||||
|
if response.mime_type == 'application/atom+xml'
|
||||||
|
return [url, fetch(url)]
|
||||||
|
elsif !response['Link'].blank?
|
||||||
|
return process_headers(url, response)
|
||||||
|
else
|
||||||
|
return process_html(fetch(url))
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue OpenSSL::SSL::SSLError => e
|
||||||
|
Rails.logger.debug "SSL error: #{e}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_html(body)
|
||||||
|
Rails.logger.debug 'Processing HTML'
|
||||||
|
|
||||||
|
page = Nokogiri::HTML(body)
|
||||||
|
alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
|
||||||
|
|
||||||
|
return nil if alternate_link.nil?
|
||||||
|
return [alternate_link['href'], fetch(alternate_link['href'])]
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_headers(url, response)
|
||||||
|
Rails.logger.debug 'Processing link header'
|
||||||
|
|
||||||
|
link_header = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
|
||||||
|
alternate_link = link_header.find_link(['rel', 'alternate'], ['type', 'application/atom+xml'])
|
||||||
|
|
||||||
|
return process_html(fetch(url)) if alternate_link.nil?
|
||||||
|
return [alternate_link.href, fetch(alternate_link.href)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(url)
|
||||||
|
http_client.get(url).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def http_client
|
||||||
|
HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50).follow
|
||||||
|
end
|
||||||
|
end
|
@@ -1,17 +0,0 @@
|
|||||||
class FetchFeedService < BaseService
|
|
||||||
# Fetch an account's feed and process it
|
|
||||||
# @param [Account] account
|
|
||||||
def call(account)
|
|
||||||
process_service.(http_client.get(account.remote_url), account)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def process_service
|
|
||||||
@process_service ||= ProcessFeedService.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def http_client
|
|
||||||
HTTP
|
|
||||||
end
|
|
||||||
end
|
|
25
app/services/fetch_remote_account_service.rb
Normal file
25
app/services/fetch_remote_account_service.rb
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class FetchRemoteAccountService < BaseService
|
||||||
|
def call(url)
|
||||||
|
atom_url, body = FetchAtomService.new.call(url)
|
||||||
|
|
||||||
|
return nil if atom_url.nil?
|
||||||
|
return process_atom(atom_url, body)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_atom(url, body)
|
||||||
|
xml = Nokogiri::XML(body)
|
||||||
|
url_parts = Addressable::URI.parse(url)
|
||||||
|
username = xml.at_xpath('//xmlns:author/xmlns:name').try(:content)
|
||||||
|
domain = url_parts.host
|
||||||
|
|
||||||
|
return nil if username.nil?
|
||||||
|
|
||||||
|
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
||||||
|
|
||||||
|
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
||||||
|
rescue Nokogiri::XML::XPath::SyntaxError
|
||||||
|
Rails.logger.debug "Invalid XML or missing namespace"
|
||||||
|
end
|
||||||
|
end
|
@@ -1,54 +1,26 @@
|
|||||||
class FetchRemoteStatusService < BaseService
|
class FetchRemoteStatusService < BaseService
|
||||||
def call(url)
|
def call(url)
|
||||||
response = http_client.head(url)
|
atom_url, body = FetchAtomService.new.call(url)
|
||||||
|
|
||||||
Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
|
return nil if atom_url.nil?
|
||||||
return nil if response.code != 200
|
return process_atom(atom_url, body)
|
||||||
|
|
||||||
if response.mime_type == 'application/atom+xml'
|
|
||||||
return process_atom(url, fetch(url))
|
|
||||||
elsif !response['Link'].blank?
|
|
||||||
return process_headers(response)
|
|
||||||
else
|
|
||||||
return process_html(fetch(url))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def process_atom(url, body)
|
def process_atom(url, body)
|
||||||
Rails.logger.debug "Processing Atom for remote status"
|
Rails.logger.debug 'Processing Atom for remote status'
|
||||||
|
|
||||||
xml = Nokogiri::XML(body)
|
xml = Nokogiri::XML(body)
|
||||||
account = extract_author(url, xml)
|
account = extract_author(url, xml)
|
||||||
|
|
||||||
return nil if account.nil?
|
return nil if account.nil?
|
||||||
|
|
||||||
statuses = ProcessFeedService.new.(body, account)
|
statuses = ProcessFeedService.new.call(body, account)
|
||||||
|
|
||||||
return statuses.first
|
return statuses.first
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_html(body)
|
|
||||||
Rails.logger.debug "Processing HTML for remote status"
|
|
||||||
|
|
||||||
page = Nokogiri::HTML(body)
|
|
||||||
alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
|
|
||||||
|
|
||||||
return nil if alternate_link.nil?
|
|
||||||
return process_atom(alternate_link['href'], fetch(alternate_link['href']))
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_headers(response)
|
|
||||||
Rails.logger.debug "Processing link header for remote status"
|
|
||||||
|
|
||||||
link_header = LinkHeader.parse(response['Link'])
|
|
||||||
alternate_link = link_header.find_link(['rel', 'alternate'], ['type', 'application/atom+xml'])
|
|
||||||
|
|
||||||
return nil if alternate_link.nil?
|
|
||||||
return process_atom(alternate_link.href, fetch(alternate_link.href))
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_author(url, xml)
|
def extract_author(url, xml)
|
||||||
url_parts = Addressable::URI.parse(url)
|
url_parts = Addressable::URI.parse(url)
|
||||||
username = xml.at_xpath('//xmlns:author/xmlns:name').try(:content)
|
username = xml.at_xpath('//xmlns:author/xmlns:name').try(:content)
|
||||||
@@ -58,14 +30,8 @@ class FetchRemoteStatusService < BaseService
|
|||||||
|
|
||||||
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
||||||
|
|
||||||
return FollowRemoteAccountService.new.("#{username}@#{domain}")
|
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
||||||
end
|
rescue Nokogiri::XML::XPath::SyntaxError
|
||||||
|
Rails.logger.debug "Invalid XML or missing namespace"
|
||||||
def fetch(url)
|
|
||||||
http_client.get(url).to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def http_client
|
|
||||||
HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -28,11 +28,11 @@ class FollowRemoteAccountService < BaseService
|
|||||||
hubs = feed.xpath('//xmlns:link[@rel="hub"]')
|
hubs = feed.xpath('//xmlns:link[@rel="hub"]')
|
||||||
|
|
||||||
if hubs.empty? || hubs.first.attribute('href').nil?
|
if hubs.empty? || hubs.first.attribute('href').nil?
|
||||||
raise Goldfinger::Error, "No PubSubHubbub hubs found"
|
raise Goldfinger::Error, 'No PubSubHubbub hubs found'
|
||||||
end
|
end
|
||||||
|
|
||||||
if feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').nil?
|
if feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').nil?
|
||||||
raise Goldfinger::Error, "No author URI found"
|
raise Goldfinger::Error, 'No author URI found'
|
||||||
end
|
end
|
||||||
|
|
||||||
account.uri = feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').content
|
account.uri = feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').content
|
||||||
@@ -53,12 +53,12 @@ class FollowRemoteAccountService < BaseService
|
|||||||
|
|
||||||
def get_profile(xml, account)
|
def get_profile(xml, account)
|
||||||
author = xml.at_xpath('/xmlns:feed/xmlns:author')
|
author = xml.at_xpath('/xmlns:feed/xmlns:author')
|
||||||
update_remote_profile_service.(author, account)
|
update_remote_profile_service.call(author, account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def magic_key_to_pem(magic_key)
|
def magic_key_to_pem(magic_key)
|
||||||
_, modulus, exponent = magic_key.split('.')
|
_, modulus, exponent = magic_key.split('.')
|
||||||
modulus, exponent = [modulus, exponent].map { |n| Base64.urlsafe_decode64(n).bytes.inject(0) { |num, byte| (num << 8) | byte } }
|
modulus, exponent = [modulus, exponent].map { |n| Base64.urlsafe_decode64(n).bytes.inject(0) { |a, e| (a << 8) | e } }
|
||||||
|
|
||||||
key = OpenSSL::PKey::RSA.new
|
key = OpenSSL::PKey::RSA.new
|
||||||
key.n = modulus
|
key.n = modulus
|
||||||
@@ -75,4 +75,3 @@ class FollowRemoteAccountService < BaseService
|
|||||||
HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50)
|
HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@@ -3,21 +3,21 @@ class FollowService < BaseService
|
|||||||
# @param [Account] source_account From which to follow
|
# @param [Account] source_account From which to follow
|
||||||
# @param [String] uri User URI to follow in the form of username@domain
|
# @param [String] uri User URI to follow in the form of username@domain
|
||||||
def call(source_account, uri)
|
def call(source_account, uri)
|
||||||
target_account = follow_remote_account_service.(uri)
|
target_account = follow_remote_account_service.call(uri)
|
||||||
|
|
||||||
return nil if target_account.nil? || target_account.id == source_account.id
|
return nil if target_account.nil? || target_account.id == source_account.id
|
||||||
|
|
||||||
follow = source_account.follow!(target_account)
|
follow = source_account.follow!(target_account)
|
||||||
|
|
||||||
if target_account.local?
|
if target_account.local?
|
||||||
NotificationMailer.follow(target_account, source_account).deliver_later
|
NotificationMailer.follow(target_account, source_account).deliver_later unless target_account.blocking?(source_account)
|
||||||
else
|
else
|
||||||
subscribe_service.(target_account)
|
subscribe_service.call(target_account)
|
||||||
NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
|
NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
merge_into_timeline(target_account, source_account)
|
merge_into_timeline(target_account, source_account)
|
||||||
source_account.ping!(account_url(source_account, format: 'atom'), [Rails.configuration.x.hub_url])
|
HubPingWorker.perform_async(source_account.id)
|
||||||
follow
|
follow
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@@ -8,9 +8,9 @@ class PostStatusService < BaseService
|
|||||||
def call(account, text, in_reply_to = nil, media_ids = nil)
|
def call(account, text, in_reply_to = nil, media_ids = nil)
|
||||||
status = account.statuses.create!(text: text, thread: in_reply_to)
|
status = account.statuses.create!(text: text, thread: in_reply_to)
|
||||||
attach_media(status, media_ids)
|
attach_media(status, media_ids)
|
||||||
process_mentions_service.(status)
|
process_mentions_service.call(status)
|
||||||
DistributionWorker.perform_async(status.id)
|
DistributionWorker.perform_async(status.id)
|
||||||
account.ping!(account_url(account, format: 'atom'), [Rails.configuration.x.hub_url])
|
HubPingWorker.perform_async(account.id)
|
||||||
status
|
status
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ class PostStatusService < BaseService
|
|||||||
def attach_media(status, media_ids)
|
def attach_media(status, media_ids)
|
||||||
return if media_ids.nil? || !media_ids.is_a?(Enumerable)
|
return if media_ids.nil? || !media_ids.is_a?(Enumerable)
|
||||||
|
|
||||||
media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map { |id| id.to_i })
|
media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i))
|
||||||
media.update(status_id: status.id)
|
media.update(status_id: status.id)
|
||||||
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