Compare commits

..

49 Commits
v0.6 ... v0.7

Author SHA1 Message Date
Eugen Rochko
9712d8ffb3 Add information on rake tasks to readme 2016-10-05 14:13:18 +02:00
Eugen Rochko
3319473b2c Move PubSubHubbub pinging to a background worker
It can take as much as 0.5s if not longer to complete
2016-10-05 13:50:21 +02:00
Eugen Rochko
2febc6ed65 Fix typo 2016-10-05 13:40:14 +02:00
Eugen Rochko
f254da17f6 Link demo video from readme 2016-10-05 13:37:47 +02:00
Eugen Rochko
fe77921e47 Catching more exceptions that slipped through, removing AR logging from
production as it's very verbose and not very useful
2016-10-05 13:26:44 +02:00
Eugen Rochko
b078885414 Catch :warden correctly 2016-10-03 19:19:03 +02:00
Eugen Rochko
feda1e6554 Catch warden throw in actioncable 2016-10-03 19:10:56 +02:00
Eugen Rochko
70e9dd0b5b Blocking will prevent e-mail notifications from blocked user, blocks in UI 2016-10-03 18:49:52 +02:00
Eugen Rochko
7b9a4af311 API for blocking and unblocking 2016-10-03 18:17:06 +02:00
Eugen Rochko
2c9e672ee2 Integrating block relationships into the API (read-only for now) 2016-10-03 17:16:58 +02:00
Eugen Rochko
9d59d7b463 Adding a block model and filter mentions from blocked users (fix #60) 2016-10-03 17:12:13 +02:00
Eugen Rochko
a488b05726 Cleaning up unused views 2016-10-03 16:52:45 +02:00
Eugen Rochko
fc198a8b4c Adding e-mail confirmations 2016-10-03 16:51:00 +02:00
Eugen Rochko
b3c7c8700d Fix most pages on small screens 2016-10-03 16:17:28 +02:00
Eugen Rochko
188c6f326b Making public pages responsive, removing reblogs/favs counts from them 2016-10-03 00:26:00 +02:00
Eugen Rochko
d74da1a89a Small fixes 2016-10-02 23:46:25 +02:00
Eugen Rochko
f06f295890 Fix doorkeeper skip_authorization 2016-10-02 22:55:09 +02:00
Eugen Rochko
9fd3d7b6cd Add since_id param to feeds 2016-10-02 22:35:27 +02:00
Eugen Rochko
a0f85774c4 Redirect after sign in to previous page (unless it's a sign in/up/etc page) 2016-10-02 17:11:08 +02:00
Eugen Rochko
6d7290f47c Add API for getting info about authenticated user: /api/v1/accounts/verify_credentials 2016-10-02 16:14:21 +02:00
Eugen Rochko
31a0202546 Slightly improved e-mail notifications 2016-10-02 15:37:35 +02:00
Eugen Rochko
2d4f9ecebf Validate against multiple reblogs of same status by same account 2016-10-02 15:33:24 +02:00
Eugen Rochko
be86d4e0a3 Preparing feeds for better filtering 2016-10-02 15:28:47 +02:00
Eugen Rochko
1fce687f8e Unreblogging and unfavouriting from the UI 2016-10-02 15:14:26 +02:00
Eugen Rochko
4909bbf415 Add logging for outgoing http requests 2016-10-02 14:58:06 +02:00
Eugen Rochko
b36197c307 Fix tests, update goldfinger to catch XML errors 2016-09-30 23:08:29 +02:00
Eugen Rochko
492224b93f Allow non-https redirect URIs for OAuth apps (AndStatus seems to require this) 2016-09-30 22:40:31 +02:00
Eugen Rochko
96ad9b0db1 Meaningful validation errors in API response 2016-09-30 22:31:16 +02:00
Eugen Rochko
62b057b085 Adjust client registration API 2016-09-30 00:03:08 +02:00
Eugen Rochko
ef2b50c9ac Deleting statuses from UI 2016-09-30 00:00:45 +02:00
Eugen Rochko
a41c3487bd Fix #56 - Add default avatar 2016-09-29 21:54:20 +02:00
Eugen Rochko
a4f7eca5fa Fix #25 - Only latest reblog of status kept on feed as zset value is set to "true" status ID 2016-09-29 21:40:37 +02:00
Eugen Rochko
927333f4f8 Improve code style 2016-09-29 21:28:21 +02:00
Eugen Rochko
e4aebad35a Fix handling of multiple Link headers (that should not be a thing though) 2016-09-27 23:49:12 +02:00
Eugen Rochko
2603c7e69f Fix tests 2016-09-27 23:23:23 +02:00
Eugen Rochko
e63aebff7a Adding landing page 2016-09-27 23:12:33 +02:00
Eugen Rochko
45a96e899e Fix #58 - disable compose form while image is uploading 2016-09-27 17:02:30 +02:00
Eugen Rochko
4f9b7432dd Fix #52 - Add API versioning (v1) 2016-09-27 16:59:08 +02:00
Eugen
3f75f52285 Fix subtle home feed filtering bug 2016-09-27 10:52:37 +02:00
Eugen Rochko
7e14eefc81 Replace logo, fix #57 - delete/unreblog/unfavourite API, fix #45 - app
registration API
2016-09-26 23:56:53 +02:00
Eugen Rochko
210362e665 Improve redirect handling in FetchAtomService 2016-09-26 17:04:05 +02:00
Eugen Rochko
c6b0311b86 Fix #54 - Fetch remote accounts by URL from mentions
Fetching atom extracted from FetchRemoteAccountService and FetchRemoteStatusService
into FetchAtomService. Mentions of the constant "http://activityschema.org/collection/public"
skipped as it's not a real URL/user.
2016-09-26 16:44:40 +02:00
Eugen Rochko
0bd4608ad1 Fix #55 - Filter self from pre-filled mentions 2016-09-26 15:49:28 +02:00
Eugen Rochko
693383234c Improved style of settings page 2016-09-25 15:48:20 +02:00
Eugen Rochko
e9bc4a4a08 Limit usernames to 30 chars, statuses to 500, open account after follow form success 2016-09-25 15:26:56 +02:00
Eugen Rochko
62b384824d Improved styling for media/videos in detailed status view 2016-09-25 14:58:07 +02:00
Eugen Rochko
15f51dbf8c Better styling for selected status in detailed view 2016-09-25 14:20:29 +02:00
Eugen
fc7b982b67 Add screenshot to readme 2016-09-24 20:44:47 +02:00
Eugen Rochko
1215ab57ff Some extra scopes for user sorting 2016-09-24 14:59:52 +02:00
198 changed files with 1856 additions and 833 deletions

14
.rubocop.yml Normal file
View File

@@ -0,0 +1,14 @@
Rails:
Enabled: true
Metrics/LineLength:
Enabled: false
Style/PerlBackrefs:
AutoCorrect: false
Style/ClassAndModuleChildren:
Enabled: false
Documentation:
Enabled: false

View File

@@ -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'

View File

@@ -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

View File

@@ -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:
[![Screenshot](https://i.imgur.com/hzWZdYT.png)][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.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -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
};
};

View File

@@ -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));
} }

View File

@@ -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));
}); });

View File

@@ -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
};
};

View File

@@ -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
};
};

View File

@@ -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));

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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;
} }

View File

@@ -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>
); );
} }

View File

@@ -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;

View File

@@ -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));
} }
} }
}, },

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'));
} }
} }
}); });

View File

@@ -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 { } else {
if (account.getIn(['relationship', 'following'])) { let blockText = '';
actionButton = <Button text='Unfollow' onClick={this.props.onUnfollow} />
if (account.getIn(['relationship', 'blocking'])) {
buttonText = 'Blocked';
disabled = true;
blockText = 'Unblock';
} else { } else {
actionButton = <Button text='Follow' onClick={this.props.onFollow} /> if (account.getIn(['relationship', 'following'])) {
buttonText = 'Unfollow';
} else {
buttonText = 'Follow';
}
if (account.getIn(['relationship', 'followed_by'])) {
infoText = 'Follows you!';
}
blockText = 'Block';
} }
if (account.getIn(['relationship', 'followed_by'])) { block = <Button text={blockText} onClick={this.props.onBlock} />;
followBack = 'Follows you!'; }
}
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>
); );
}, },

View File

@@ -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 () {
this.props.dispatch(followAccount(this.props.account.get('id'))); 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')));
}
}, },
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) {
this.props.dispatch(reblog(status)); if (status.get('reblogged')) {
this.props.dispatch(unreblog(status));
} else {
this.props.dispatch(reblog(status));
}
}, },
handleFavourite (status) { handleFavourite (status) {
this.props.dispatch(favourite(status)); if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
} else {
this.props.dispatch(favourite(status));
}
},
handleDelete (status) {
this.props.dispatch(deleteStatus(status.get('id')));
}, },
handleScrollToBottom () { handleScrollToBottom () {
@@ -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>
); );
} }

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,12 +1,14 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { 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 { favourite, reblog } from '../../actions/interactions'; import DetailedStatus from './components/detailed_status';
import { replyCompose } from '../../actions/compose'; import ActionBar from './components/action_bar';
import { selectStatus } from '../../reducers/timelines'; import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose';
import { selectStatus } from '../../reducers/timelines';
function selectStatuses(state, ids) { function selectStatuses(state, ids) {
return ids.map(id => selectStatus(state, id)).filterNot(status => status === null); return ids.map(id => selectStatus(state, id)).filterNot(status => status === null);
@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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,
@@ -39,7 +40,8 @@ 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>
); );

View File

@@ -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 () {

View File

@@ -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']))
}; };
}; };

View File

@@ -15,8 +15,8 @@ const mapDispatchToProps = function (dispatch) {
dispatch(changeFollow(text)); dispatch(changeFollow(text));
}, },
onSubmit: function () { onSubmit: function (router) {
dispatch(submitFollow()); dispatch(submitFollow(router));
} }
} }
}; };

View File

@@ -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) {
dispatch(favourite(status)); if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
}, },
onReblog: function (status) { onReblog (status) {
dispatch(reblog(status)); if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
dispatch(reblog(status));
}
}, },
onScrollToBottom: function () { onScrollToBottom () {
dispatch(expandTimeline(props.type)); dispatch(expandTimeline(props.type));
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
} }
}; };
}; };

View File

@@ -10,9 +10,10 @@ import {
COMPOSE_UPLOAD_FAIL, COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO, COMPOSE_UPLOAD_UNDO,
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 Immutable from 'immutable'; import { ACCOUNT_SET_SELF } from '../actions/accounts';
import Immutable from 'immutable';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
text: '', text: '',
@@ -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;
} }

View File

@@ -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);

View File

@@ -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));

View 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;
}
}
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;
}
}
}

View File

@@ -56,11 +56,28 @@
} }
} }
&.activity-stream-embedded { @media screen and (max-width: 700px) {
border-radius: 0;
box-shadow: none; box-shadow: none;
.entry { .entry {
border-radius: 0; &:last-child {
border-radius: 0;
}
&:first-child {
border-radius: 0;
&:last-child {
border-radius: 0;
}
}
}
}
@media screen and (max-width: 360px) {
.avatar {
display: none;
} }
} }
@@ -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;

View File

@@ -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

View File

@@ -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
verified_user = env['warden'].user catch :warden do
verified_user = env['warden'].user
if verified_user return verified_user if verified_user
verified_user
else
reject_unauthorized_connection
end end
reject_unauthorized_connection
end end
end end
end end

View File

@@ -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}"

View File

@@ -0,0 +1,5 @@
class AboutController < ApplicationController
def index
@body_classes = 'about-body'
end
end

View File

@@ -7,16 +7,7 @@ class AccountsController < ApplicationController
def show def show
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth'
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -8,22 +8,16 @@ class StreamEntriesController < ApplicationController
def show def show
@type = @stream_entry.activity_type.downcase @type = @stream_entry.activity_type.downcase
if @stream_entry.activity_type == 'Status'
@ancestors = @stream_entry.activity.ancestors
@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
respond_to do |format| respond_to do |format|
format.html format.html do
return gone if @stream_entry.activity.nil?
if @stream_entry.activity_type == 'Status'
@ancestors = @stream_entry.activity.ancestors
@descendants = @stream_entry.activity.descendants
end
end
format.atom format.atom
end end
end end

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -1,2 +0,0 @@
module Api::AccountsHelper
end

View File

@@ -1,2 +0,0 @@
module Api::FollowsHelper
end

View File

@@ -1,2 +0,0 @@
module Api::MediaHelper
end

View File

@@ -1,2 +0,0 @@
module Api::SalmonHelper
end

View File

@@ -1,2 +0,0 @@
module Api::StatusesHelper
end

View File

@@ -1,2 +0,0 @@
module Api::SubscriptionsHelper
end

View File

@@ -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

View File

@@ -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

View File

@@ -1,2 +0,0 @@
module Oauth::ApplicationsHelper
end

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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