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 'http'
gem 'httplog'
gem 'addressable'
gem 'nokogiri'
gem 'link_header'

View File

@@ -74,6 +74,7 @@ GEM
coffee-script-source
execjs
coffee-script-source (1.10.0)
colorize (0.8.1)
concurrent-ruby (1.0.2)
connection_pool (2.2.0)
crack (0.4.3)
@@ -106,9 +107,9 @@ GEM
ruby-progressbar (~> 1.4)
globalid (0.3.7)
activesupport (>= 4.1.0)
goldfinger (1.0.3)
goldfinger (1.0.5)
addressable (~> 2.4)
http (~> 1.0)
http (~> 2.0)
nokogiri (~> 1.6)
hamlit (2.7.2)
temple (~> 0.7.6)
@@ -122,15 +123,17 @@ GEM
hashdiff (0.3.0)
hiredis (0.6.1)
htmlentities (4.3.4)
http (1.0.4)
http (2.0.3)
addressable (~> 2.3)
http-cookie (~> 1.0)
http-form_data (~> 1.0.1)
http_parser.rb (~> 0.6.0)
http-cookie (1.0.2)
http-cookie (1.0.3)
domain_name (~> 0.5)
http-form_data (1.0.1)
http_parser.rb (0.6.0)
httplog (0.3.2)
colorize
i18n (0.7.0)
jbuilder (2.6.0)
activesupport (>= 3.0.0, < 5.1)
@@ -168,9 +171,9 @@ GEM
pkg-config (~> 1.1.7)
oj (2.17.3)
orm_adapter (0.5.0)
ostatus2 (0.3)
ostatus2 (0.3.1)
addressable (~> 2.4)
http (~> 1.0)
http (~> 2.0)
nokogiri (~> 1.6)
paperclip (4.3.7)
activemodel (>= 3.2.0)
@@ -360,6 +363,7 @@ DEPENDENCIES
hiredis
htmlentities
http
httplog
jbuilder (~> 2.0)
jquery-rails
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.
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.
If you would like, you can [support the development of this project on Patreon][patreon]. Alternatively, you can donate to this BTC address: `17j2g7vpgHhLuXhN4bueZFCvdxxieyRVWd`
@@ -24,19 +30,19 @@ If you would like, you can [support the development of this project on Patreon][
## Features
- **Fully interoperable with GNU social and any OStatus platform**
- **Fully interoperable with GNU social and any OStatus platform**
Whatever implements Atom feeds, ActivityStreams, Salmon, PubSubHubbub and Webfinger is part of the network
- **Real-time timeline updates**
- **Real-time timeline updates**
See the updates of people you're following appear in real-time in the UI via WebSockets
- **Federated thread resolving**
- **Federated thread resolving**
If someone you follow replies to a user unknown to the server, the server fetches the full thread so you can view it without leaving the UI
- **Media attachments like images and WebM**
- **Media attachments like images and WebM**
Upload and view images and WebM videos attached to the updates
- **OAuth2 and a straightforward REST API**
- **OAuth2 and a straightforward REST API**
Mastodon acts as an OAuth2 provider so 3rd party apps can use the API, which is RESTful and simple
- **Background processing for long-running tasks**
- **Background processing for long-running tasks**
Mastodon tries to be as fast and responsive as possible, so all long-running tasks that can be delegated to background processing, are
- **Deployable via Docker**
- **Deployable via Docker**
You don't need to mess with dependencies and configuration if you want to try Mastodon, if you have Docker and Docker Compose the deployment is extremely easy
## Configuration
@@ -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
- `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
@@ -64,14 +70,27 @@ And finally
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:
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.
**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
This approach makes updating to the latest version a real breeze.
@@ -87,3 +106,7 @@ And finally,
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.
### 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_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_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL';
@@ -36,7 +44,7 @@ export function fetchAccount(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]));
}).catch(error => {
dispatch(fetchAccountFail(id, error));
@@ -48,7 +56,7 @@ export function fetchAccountTimeline(id) {
return (dispatch, getState) => {
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));
}).catch(error => {
dispatch(fetchAccountTimelineFail(id, error));
@@ -62,7 +70,7 @@ export function expandAccountTimeline(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));
}).catch(error => {
dispatch(expandAccountTimelineFail(id, error));
@@ -97,7 +105,7 @@ export function followAccount(id) {
return (dispatch, getState) => {
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));
}).catch(error => {
dispatch(followAccountFail(error));
@@ -109,7 +117,7 @@ export function unfollowAccount(id) {
return (dispatch, getState) => {
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));
}).catch(error => {
dispatch(unfollowAccountFail(error));
@@ -204,3 +212,69 @@ export function expandAccountTimelineFail(id, 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) {
dispatch(submitComposeRequest());
api(getState).post('/api/statuses', {
api(getState).post('/api/v1/statuses', {
status: getState().getIn(['compose', 'text'], ''),
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
@@ -75,7 +75,7 @@ export function uploadCompose(files) {
let data = new FormData();
data.append('file', files[0]);
api(getState).post('/api/media', data, {
api(getState).post('/api/v1/media', data, {
onUploadProgress: function (e) {
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) {
dispatch(submitFollowRequest());
api(getState).post('/api/follows', {
api(getState).post('/api/v1/follows', {
uri: getState().getIn(['follow', 'text'])
}).then(function (response) {
dispatch(submitFollowSuccess(response.data));
router.push(`/accounts/${response.data.id}`);
}).catch(function (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_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) {
return function (dispatch, getState) {
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
// interested in how the original is modified, hence passing it skipping the wrapper
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) {
return {
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) {
return function (dispatch, getState) {
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));
}).catch(function (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) {
return {
type: FAVOURITE_REQUEST,
@@ -79,3 +134,26 @@ export function favouriteFail(status, 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_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) {
return {
type: STATUS_FETCH_REQUEST,
@@ -18,7 +22,7 @@ export function fetchStatus(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));
}).catch(error => {
dispatch(fetchStatusFail(id, error));
@@ -41,3 +45,37 @@ export function fetchStatusFail(id, 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) {
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));
}).catch(function (error) {
dispatch(refreshTimelineFail(timeline, error));
@@ -67,7 +67,7 @@ export function expandTimeline(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));
}).catch(error => {
dispatch(expandTimelineFail(timeline, error));

View File

@@ -19,8 +19,29 @@ const Button = React.createClass({
},
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 (
<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}
</button>
);

View File

@@ -26,8 +26,16 @@ const IconButton = React.createClass({
},
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 (
<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>
</a>
);

View File

@@ -22,6 +22,10 @@ const MediaGallery = React.createClass({
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && i > 0)) {
height = 50;
}

View File

@@ -2,11 +2,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from './avatar';
import RelativeTimestamp from './relative_timestamp';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import IconButton from './icon_button';
import DisplayName from './display_name';
import MediaGallery from './media_gallery';
import VideoPlayer from './video_player';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
const Status = React.createClass({
@@ -19,23 +19,13 @@ const Status = React.createClass({
wrapped: React.PropTypes.bool,
onReply: 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],
handleReplyClick () {
this.props.onReply(this.props.status);
},
handleFavouriteClick () {
this.props.onFavourite(this.props.status);
},
handleReblogClick () {
this.props.onReblog(this.props.status);
},
handleClick () {
const { status } = this.props;
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={{ fontSize: '15px' }}>
<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>
<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}
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
<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>
<StatusActionBar {...this.props} />
</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 {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
link.addEventListener('click', this.onNormalClick);
link.addEventListener('click', this.onNormalClick.bind(this));
}
}
},
@@ -36,7 +36,7 @@ const StatusContent = React.createClass({
e.preventDefault();
this.context.router.push(`/accounts/${mention.get('id')}`);
}
e.stopPropagation();
},

View File

@@ -9,7 +9,9 @@ const StatusList = React.createClass({
onReply: React.PropTypes.func,
onReblog: 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],
@@ -23,11 +25,13 @@ const StatusList = React.createClass({
},
render () {
const { statuses, onScrollToBottom, ...other } = this.props;
return (
<div style={{ overflowY: 'scroll', flex: '1 1 auto' }} className='scrollable' onScroll={this.handleScroll}>
<div>
{this.props.statuses.map((status) => {
return <Status key={status.get('id')} status={status} onReply={this.props.onReply} onReblog={this.props.onReblog} onFavourite={this.props.onFavourite} />;
{statuses.map((status) => {
return <Status key={status.get('id')} {...other} status={status} />;
})}
</div>
</div>

View File

@@ -4,7 +4,16 @@ import IconButton from './icon_button';
const VideoPlayer = React.createClass({
propTypes: {
media: ImmutablePropTypes.map.isRequired
media: ImmutablePropTypes.map.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number
},
getDefaultProps () {
return {
width: 196,
height: 110
};
},
getInitialState () {
@@ -21,7 +30,7 @@ const VideoPlayer = React.createClass({
render () {
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>
<video src={this.props.media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={{ width: '100%', height: '100%' }} />
</div>

View File

@@ -40,11 +40,15 @@ const Mastodon = React.createClass({
if (typeof App !== 'undefined') {
App.timeline = App.cable.subscriptions.create("TimelineChannel", {
connected: function() {},
connected () {
disconnected: function() {},
},
received: function(data) {
disconnected () {
},
received (data) {
switch(data.type) {
case 'update':
return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
@@ -53,6 +57,8 @@ const Mastodon = React.createClass({
case 'merge':
case 'unmerge':
return store.dispatch(refreshTimeline('home'));
case 'block':
return store.dispatch(refreshTimeline('mentions'));
}
}
});

View File

@@ -8,34 +8,55 @@ const ActionBar = React.createClass({
account: ImmutablePropTypes.map.isRequired,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onUnfollow: React.PropTypes.func.isRequired
onBlock: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
render () {
const { account, me } = this.props;
let followBack = '';
let actionButton = '';
let infoText = '';
let follow = '';
let buttonText = '';
let block = '';
let disabled = false;
if (account.get('id') === me) {
actionButton = 'This is you!';
buttonText = 'This is you!';
disabled = true;
} else {
if (account.getIn(['relationship', 'following'])) {
actionButton = <Button text='Unfollow' onClick={this.props.onUnfollow} />
let blockText = '';
if (account.getIn(['relationship', 'blocking'])) {
buttonText = 'Blocked';
disabled = true;
blockText = 'Unblock';
} 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'])) {
followBack = 'Follows you!';
}
block = <Button text={blockText} onClick={this.props.onBlock} />;
}
if (!account.getIn(['relationship', 'blocking'])) {
follow = <Button text={buttonText} onClick={this.props.onFollow} disabled={disabled} />;
}
return (
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px' }}>
{actionButton} {followBack}
<div style={{ borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px', lineHeight: '36px', overflow: 'hidden', flex: '0 0 auto' }}>
{follow} {block}
<span style={{ color: '#616b86', fontWeight: '500', textTransform: 'uppercase', float: 'right', display: 'block' }}>{infoText}</span>
</div>
);
},

View File

@@ -5,11 +5,19 @@ import {
fetchAccount,
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
fetchAccountTimeline,
expandAccountTimeline
} from '../../actions/accounts';
import { deleteStatus } from '../../actions/statuses';
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 {
selectStatus,
@@ -53,11 +61,19 @@ const Account = React.createClass({
},
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 () {
this.props.dispatch(unfollowAccount(this.props.account.get('id')));
handleBlock () {
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) {
@@ -65,11 +81,23 @@ const Account = React.createClass({
},
handleReblog (status) {
this.props.dispatch(reblog(status));
if (status.get('reblogged')) {
this.props.dispatch(unreblog(status));
} else {
this.props.dispatch(reblog(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 () {
@@ -86,8 +114,8 @@ const Account = React.createClass({
return (
<div style={{ display: 'flex', flexDirection: 'column', 'flex': '0 0 auto', height: '100%' }}>
<Header account={account} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onUnfollow={this.handleUnfollow} />
<StatusList statuses={statuses} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
<ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} />
<StatusList statuses={statuses} me={me} onScrollToBottom={this.handleScrollToBottom} onReply={this.handleReply} onReblog={this.handleReblog} onFavourite={this.handleFavourite} />
</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 PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchStatus } from '../../actions/statuses';
import Immutable from 'immutable';
import EmbeddedStatus from '../../components/status';
import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose';
import { selectStatus } from '../../reducers/timelines';
import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { fetchStatus } from '../../actions/statuses';
import Immutable from 'immutable';
import EmbeddedStatus from '../../components/status';
import DetailedStatus from './components/detailed_status';
import ActionBar from './components/action_bar';
import { favourite, reblog } from '../../actions/interactions';
import { replyCompose } from '../../actions/compose';
import { selectStatus } from '../../reducers/timelines';
function selectStatuses(state, ids) {
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>{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>

View File

@@ -3,15 +3,18 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
const CharacterCounter = React.createClass({
propTypes: {
text: React.PropTypes.string.isRequired
text: React.PropTypes.string.isRequired,
max: React.PropTypes.number.isRequired
},
mixins: [PureRenderMixin],
render () {
const diff = this.props.max - this.props.text.length;
return (
<span style={{ fontSize: '16px', cursor: 'default' }}>
{this.props.text.length}
{diff}
</span>
);
}

View File

@@ -10,6 +10,7 @@ const ComposeForm = React.createClass({
propTypes: {
text: React.PropTypes.string.isRequired,
is_submitting: React.PropTypes.bool,
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
onChange: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
@@ -39,7 +40,8 @@ const ComposeForm = React.createClass({
},
render () {
let replyArea = '';
let replyArea = '';
const disabled = this.props.is_submitting || this.props.is_uploading;
if (this.props.in_reply_to) {
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
@@ -49,11 +51,11 @@ const ComposeForm = React.createClass({
<div style={{ padding: '10px' }}>
{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={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={this.props.is_submitting} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter text={this.props.text} /></div>
<div style={{ float: 'right' }}><Button text='Publish' onClick={this.handleSubmit} disabled={disabled} /></div>
<div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div>
</div>
</div>
);

View File

@@ -3,6 +3,10 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
const FollowForm = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
text: React.PropTypes.string.isRequired,
is_submitting: React.PropTypes.bool,
@@ -18,12 +22,12 @@ const FollowForm = React.createClass({
handleKeyUp (e) {
if (e.keyCode === 13) {
this.props.onSubmit();
this.handleSubmit();
}
},
handleSubmit () {
this.props.onSubmit();
this.props.onSubmit(this.context.router);
},
render () {

View File

@@ -7,6 +7,7 @@ const mapStateToProps = function (state, props) {
return {
text: state.getIn(['compose', 'text']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: selectStatus(state, state.getIn(['compose', 'in_reply_to']))
};
};

View File

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

View File

@@ -1,32 +1,51 @@
import { connect } from 'react-redux';
import StatusList from '../../../components/status_list';
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 { selectStatus } from '../../../reducers/timelines';
import { deleteStatus } from '../../../actions/statuses';
const mapStateToProps = function (state, props) {
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) {
return {
onReply: function (status) {
onReply (status) {
dispatch(replyCompose(status));
},
onFavourite: function (status) {
dispatch(favourite(status));
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onReblog: function (status) {
dispatch(reblog(status));
onReblog (status) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
dispatch(reblog(status));
}
},
onScrollToBottom: function () {
onScrollToBottom () {
dispatch(expandTimeline(props.type));
},
onDelete (status) {
dispatch(deleteStatus(status.get('id')));
}
};
};

View File

@@ -10,9 +10,10 @@ import {
COMPOSE_UPLOAD_FAIL,
COMPOSE_UPLOAD_UNDO,
COMPOSE_UPLOAD_PROGRESS
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import Immutable from 'immutable';
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
import { ACCOUNT_SET_SELF } from '../actions/accounts';
import Immutable from 'immutable';
const initialState = Immutable.Map({
text: '',
@@ -20,11 +21,19 @@ const initialState = Immutable.Map({
is_submitting: false,
is_uploading: false,
progress: 0,
media_attachments: Immutable.List([])
media_attachments: Immutable.List([]),
me: null
});
function statusToTextMentions(status) {
return Immutable.OrderedSet([`@${status.getIn(['account', 'acct'])} `]).union(status.get('mentions').map(mention => `@${mention.get('acct')} `)).join('');
function statusToTextMentions(state, status) {
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) {
@@ -60,7 +69,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_REPLY:
return state.withMutations(map => {
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:
return state.withMutations(map => {
@@ -89,6 +98,8 @@ export default function compose(state = initialState, action) {
} else {
return state;
}
case ACCOUNT_SET_SELF:
return state.set('me', action.account.id);
default:
return state;
}

View File

@@ -1,6 +1,11 @@
import { COMPOSE_SUBMIT_FAIL, COMPOSE_UPLOAD_FAIL } from '../actions/compose';
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 {
TIMELINE_REFRESH_FAIL,
TIMELINE_EXPAND_FAIL
@@ -13,7 +18,10 @@ import {
ACCOUNT_TIMELINE_FETCH_FAIL,
ACCOUNT_TIMELINE_EXPAND_FAIL
} from '../actions/accounts';
import { STATUS_FETCH_FAIL } from '../actions/statuses';
import {
STATUS_FETCH_FAIL,
STATUS_DELETE_FAIL
} from '../actions/statuses';
import Immutable from 'immutable';
const initialState = Immutable.List();
@@ -51,6 +59,9 @@ export default function notifications(state = initialState, action) {
case ACCOUNT_TIMELINE_FETCH_FAIL:
case ACCOUNT_TIMELINE_EXPAND_FAIL:
case STATUS_FETCH_FAIL:
case STATUS_DELETE_FAIL:
case UNREBLOG_FAIL:
case UNFAVOURITE_FAIL:
return notificationFromError(state, action.error);
case NOTIFICATION_DISMISS:
return state.filterNot(item => item.get('key') === action.notification.key);

View File

@@ -6,17 +6,24 @@ import {
} from '../actions/timelines';
import {
REBLOG_SUCCESS,
FAVOURITE_SUCCESS
UNREBLOG_SUCCESS,
FAVOURITE_SUCCESS,
UNFAVOURITE_SUCCESS
} from '../actions/interactions';
import {
ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS,
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts';
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 Immutable from 'immutable';
@@ -142,10 +149,28 @@ function updateTimeline(state, timeline, status) {
};
function deleteStatus(state, id) {
const status = state.getIn(['statuses', id]);
if (!status) {
return state;
}
// Remove references from timelines
['home', 'mentions'].forEach(function (timeline) {
state = state.update(timeline, list => list.filterNot(item => item === id));
});
// Remove references from account timelines
state = state.updateIn(['accounts_timelines', status.get('account')], Immutable.List(), list => list.filterNot(item => item === id));
// 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]);
};
@@ -153,7 +178,7 @@ function normalizeAccount(state, account, relationship) {
if (relationship) {
state = normalizeRelationship(state, relationship);
}
return state.setIn(['accounts', account.get('id')], account);
};
@@ -194,9 +219,12 @@ export default function timelines(state = initialState, action) {
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, Immutable.fromJS(action.status));
case TIMELINE_DELETE:
case STATUS_DELETE_SUCCESS:
return deleteStatus(state, action.id);
case REBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNREBLOG_SUCCESS:
case UNFAVOURITE_SUCCESS:
return normalizeStatus(state, Immutable.fromJS(action.response));
case ACCOUNT_SET_SELF:
return setSelf(state, Immutable.fromJS(action.account));
@@ -205,6 +233,8 @@ export default function timelines(state = initialState, action) {
return normalizeAccount(state, Immutable.fromJS(action.account), Immutable.fromJS(action.relationship));
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_UNBLOCK_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
return normalizeRelationship(state, Immutable.fromJS(action.relationship));
case STATUS_FETCH_SUCCESS:
return normalizeContext(state, Immutable.fromJS(action.status), Immutable.fromJS(action.context.ancestors), Immutable.fromJS(action.context.descendants));

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;
position: relative;
@media screen and (max-width: 700px) {
border-radius: 0;
box-shadow: none;
}
&:after {
background: rgba(0, 0, 0, 0.5);
display: block;
@@ -58,6 +63,13 @@
margin-top: 30px;
position: relative;
z-index: 2;
flex-direction: row;
}
.details-counters {
display: flex;
flex-direction: row;
order: 0;
}
.counter {
@@ -124,6 +136,26 @@
line-height: 18px;
padding: 5px 10px;
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;
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 {
@@ -192,6 +236,11 @@
padding-bottom: 10px;
overflow: hidden;
@media screen and (max-width: 700px) {
border-radius: 0;
box-shadow: none;
}
.account-grid-card {
box-sizing: border-box;
width: 335px;

View File

@@ -76,6 +76,10 @@ body {
height: 100%;
padding: 0;
}
@media screen and (max-width: 360px) {
padding-bottom: 0;
}
}
.app-holder {
@@ -90,13 +94,23 @@ body {
width: 700px;
margin: 0 auto;
margin-top: 40px;
@media screen and (max-width: 700px) {
width: 100%;
margin: 0;
}
}
.logo-container {
width: 400px;
max-width: 400px;
margin: 100px auto;
margin-bottom: 0;
cursor: default;
@media screen and (max-width: 360px) {
margin: 30px auto;
}
h1 {
display: block;
text-align: center;
@@ -104,10 +118,29 @@ body {
font-size: 48px;
font-weight: 500;
img {
display: block;
margin: 20px auto;
width: 180px;
height: 180px;
}
a {
color: inherit;
text-decoration: none;
outline: 0;
img {
opacity: 0.8;
transition: all 0.8s ease;
}
&:hover {
img {
opacity: 1;
transition-duration: 0.2s;
}
}
}
small {
@@ -120,14 +153,31 @@ body {
}
.form-container {
width: 400px;
max-width: 400px;
margin: 0 auto;
padding: 20px;
.field {
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;
border: 0;
border-bottom: 2px solid #9baec8;
@@ -137,6 +187,7 @@ body {
display: block;
width: 100%;
outline: 0;
font-family: 'Roboto';
&:invalid {
box-shadow: none;
@@ -226,6 +277,13 @@ body {
}
}
.flash-message {
text-align: center;
font-size: 14px;
margin-bottom: 30px;
font-weight: 500;
}
.form-footer {
margin-top: 30px;
text-align: center;
@@ -278,6 +336,11 @@ body {
font-size: 12px;
font-weight: 400;
font-family: 'Roboto Mono', monospace;
a {
color: inherit;
text-decoration: none;
}
}
.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 'stream_entries';
@import 'components'
@import 'components';
@import 'about';

View File

@@ -1,5 +1,22 @@
.button {
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 {
background-color: #489fde;
@@ -7,6 +24,7 @@
&:disabled {
background-color: #9baec8;
cursor: default;
}
&.button-secondary {
@@ -82,6 +100,13 @@
}
}
.detailed-status {
.status__content {
font-size: 19px;
line-height: 24px;
}
}
.reply-indicator__content {
color: #282c37;
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;
}
@@ -101,7 +126,7 @@
}
}
.status__display-name, .reply-indicator__display-name {
.status__display-name, .reply-indicator__display-name, .detailed-status__display-name {
&:hover {
strong {
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 {
text-decoration: underline;
}
@@ -118,3 +157,54 @@
.transparent-background {
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;
.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;
height: 56px;
display: block;
border-radius: 56px;
border-radius: 4px;
}
}
@@ -96,27 +113,7 @@
}
.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 {
@@ -205,6 +202,7 @@
display: block;
overflow: hidden;
padding-left: 10px;
margin-bottom: 21px;
li {
display: block;
@@ -213,7 +211,7 @@
height: 100px;
border-radius: 4px;
margin-right: 4px;
margin-bottom: 25px;
margin-bottom: 4px;
a {
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
class Channel < ActionCable::Channel::Base
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
class Connection < ActionCable::Connection::Base
identified_by :current_user
@@ -10,13 +9,12 @@ module ApplicationCable
protected
def find_verified_user
verified_user = env['warden'].user
if verified_user
verified_user
else
reject_unauthorized_connection
catch :warden do
verified_user = env['warden'].user
return verified_user if verified_user
end
reject_unauthorized_connection
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
def subscribed
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
respond_to do |format|
format.html do
@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
@statuses = @account.statuses.order('id desc').with_includes.with_counters.paginate(page: params[:page], per_page: 10)
end
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
def update
ProcessInteractionService.new.(request.body.read, @account)
ProcessInteractionService.new.call(request.body.read, @account)
head 201
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
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
else
head 404
@@ -15,7 +15,7 @@ class Api::SubscriptionsController < ApiController
body = request.body.read
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
else
head 202

View File

@@ -1,11 +1,16 @@
class Api::AccountsController < ApiController
class Api::V1::AccountsController < ApiController
before_action :doorkeeper_authorize!
before_action :set_account
before_action :set_account, except: :verify_credentials
respond_to :json
def show
end
def verify_credentials
@account = current_user.account
render action: :show
end
def following
@following = @account.following
end
@@ -15,27 +20,39 @@ class Api::AccountsController < ApiController
end
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
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
render action: :relationship
end
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
render action: :relationship
end
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)
@following = Account.following_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
private
@@ -47,6 +64,6 @@ class Api::AccountsController < ApiController
def set_relationship
@following = Account.following_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

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!
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
skip_before_action :verify_authenticity_token
rescue_from ActiveRecord::RecordInvalid do
render json: { error: 'Record invalid' }, status: 422
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: e.to_s }, status: 422
end
rescue_from ActiveRecord::RecordNotFound do
@@ -18,6 +18,10 @@ class ApiController < ApplicationController
render json: { error: 'Remote data could not be fetched' }, status: 503
end
rescue_from OpenSSL::SSL::SSLError do
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
end
protected
def current_resource_owner
@@ -27,4 +31,8 @@ class ApiController < ApplicationController
def current_user
super || current_resource_owner
end
def render_empty
render json: {}, status: 200
end
end

View File

@@ -10,8 +10,16 @@ class ApplicationController < ActionController::Base
rescue_from ActionController::RoutingError, with: :not_found
rescue_from ActiveRecord::RecordNotFound, with: :not_found
before_action :store_current_location, unless: :devise_controller?
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
protected
@@ -22,6 +30,12 @@ class ApplicationController < ActionController::Base
end
end
def gone
respond_to do |format|
format.any { head 410 }
end
end
def current_account
current_user.try(:account)
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
layout 'auth'
before_filter :configure_sign_up_params, only: [:create]
before_action :configure_sign_up_params, only: [:create]
protected
def build_resource(hash = nil)
super(hash)
self.resource.build_account if self.resource.account.nil?
resource.build_account if resource.account.nil?
end
def configure_sign_up_params

View File

@@ -8,4 +8,16 @@ class Auth::SessionsController < Devise::SessionsController
remember_me(resource)
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

View File

@@ -10,6 +10,10 @@ class HomeController < ApplicationController
private
def authenticate_user!
redirect_to about_path unless user_signed_in?
end
def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(Doorkeeper::Application.where(superapp: true).first, current_user.id, nil, Doorkeeper.configuration.access_token_expires_in, Doorkeeper.configuration.refresh_token_enabled?)
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
layout 'auth'
before_action :authenticate_user!
before_action :set_account

View File

@@ -8,22 +8,16 @@ class StreamEntriesController < ApplicationController
def show
@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|
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
end
end

View File

@@ -31,9 +31,9 @@ class XrdController < ApplicationController
def pem_to_magic_key(public_key)
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')
component >>= 8
end
@@ -41,7 +41,7 @@ class XrdController < ApplicationController
result.reverse!
end
(["RSA"] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.')
(['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.')
end
def resource_param

View File

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

View File

@@ -1,5 +1,10 @@
module AccountsHelper
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

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

View File

@@ -3,11 +3,11 @@ module HomeHelper
{
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: {
home: render(file: 'api/statuses/home', locals: { statuses: @home }, formats: :json),
mentions: render(file: 'api/statuses/mentions', locals: { statuses: @mentions }, formats: :json)
home: render(file: 'api/v1/statuses/home', locals: { statuses: @home }, formats: :json),
mentions: render(file: 'api/v1/statuses/mentions', locals: { statuses: @mentions }, formats: :json)
}
}
end

View File

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

View File

@@ -17,15 +17,15 @@ module StreamEntriesHelper
end
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
def reblogged_by_me_class(status)
user_signed_in? && @reblogged.has_key?(status.id) ? 'reblogged' : ''
user_signed_in? && @reblogged.key?(status.id) ? 'reblogged' : ''
end
def favourited_by_me_class(status)
user_signed_in? && @favourited.has_key?(status.id) ? 'favourited' : ''
user_signed_in? && @favourited.key?(status.id) ? 'favourited' : ''
end
def proper_status(status)

View File

@@ -9,13 +9,16 @@ class FeedManager
"feed:#{type}:#{id}"
end
def filter_status?(status, follower)
replied_to_user = status.reply? ? status.thread.account : nil
(status.reply? && !(follower.id = replied_to_user.id || follower.following?(replied_to_user)))
def filter?(timeline_type, status, receiver)
if timeline_type == :home
filter_from_home?(status, receiver)
else
filter_from_mentions?(status, receiver)
end
end
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)
broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status))
end
@@ -36,6 +39,16 @@ class FeedManager
$redis
end
# Filter status out of the home feed if it is a reply to someone the user doesn't follow
def filter_from_home?(status, receiver)
replied_to_user = status.reply? ? status.thread.account : nil
(status.reply? && !(receiver.id == replied_to_user.id || replied_to_user.id == status.account_id || receiver.following?(replied_to_user)))
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)
rabl_scope = Class.new do
include RoutingHelper
@@ -53,6 +66,6 @@ class FeedManager
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

View File

@@ -35,7 +35,7 @@ class Formatter
def link_mentions(html, mentions)
html.gsub(Account::MENTION_RE) do |match|
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)
end

View File

@@ -2,11 +2,11 @@ class Account < ApplicationRecord
include Targetable
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
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?'
# Avatar upload
@@ -33,8 +33,12 @@ class Account < ApplicationRecord
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
has_many :following, through: :active_relationships, source: :target_account
has_many :followers, through: :passive_relationships, source: :account
has_many :following, -> { order('follows.created_at desc') }, through: :active_relationships, source: :target_account
has_many :followers, -> { order('follows.created_at desc') }, through: :passive_relationships, source: :account
# 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
@@ -45,28 +49,41 @@ class Account < ApplicationRecord
scope :expiring, -> (time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
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
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?
end
def unblock!(other_account)
block = block_relationships.find_by(target_account: other_account)
block.destroy unless block.nil?
end
def following?(other_account)
following.include?(other_account)
end
def blocking?(other_account)
blocking.include?(other_account)
end
def local?
self.domain.nil?
domain.nil?
end
def acct
local? ? self.username : "#{self.username}@#{self.domain}"
local? ? username : "#{username}@#{domain}"
end
def subscribed?
!self.subscription_expires_at.nil?
!subscription_expires_at.nil?
end
def favourited?(status)
@@ -78,11 +95,11 @@ class Account < ApplicationRecord
end
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
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
def ping!(atom_url, hubs)
@@ -91,10 +108,7 @@ class Account < ApplicationRecord
end
def avatar_remote_url=(url)
unless self[:avatar_remote_url] == url
self.avatar = URI.parse(url)
end
self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url
self[:avatar_remote_url] = url
end
@@ -103,26 +117,25 @@ class Account < ApplicationRecord
end
def to_param
self.username
username
end
def self.find_local!(username)
self.find_remote!(username, nil)
find_remote!(username, nil)
end
def self.find_remote!(username, domain)
table = self.arel_table
self.where(table[:username].matches(username)).where(domain: domain).take!
where(arel_table[:username].matches(username)).where(domain: domain).take!
end
def self.find_local(username)
self.find_local!(username)
find_local!(username)
rescue ActiveRecord::RecordNotFound
nil
end
def self.find_remote(username, domain)
self.find_remote!(username, domain)
find_remote!(username, domain)
rescue ActiveRecord::RecordNotFound
nil
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
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
if local?
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
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

View File

@@ -25,7 +25,7 @@ module Streamable
end
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

View File

@@ -11,15 +11,13 @@ class Favourite < ApplicationRecord
end
def title
"#{self.account.acct} favourited a status by #{self.status.account.acct}"
"#{account.acct} favourited a status by #{status.account.acct}"
end
def object_type
target.object_type
end
delegate :object_type, to: :target
def thread
self.status
status
end
def target

View File

@@ -4,16 +4,17 @@ class Feed
@account = account
end
def get(limit, max_id = nil)
max_id = '+inf' if max_id.nil?
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", '-inf', limit: [0, limit])
status_map = Hash.new
def get(limit, max_id = nil, since_id = nil)
max_id = '+inf' if max_id.blank?
since_id = '-inf' if since_id.blank?
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).collect(&:last).map(&:to_i)
status_map = {}
# If we're after most recent items and none are there, we need to precompute the feed
if unhydrated.empty? && max_id == '+inf'
PrecomputeFeedService.new.(@type, @account, limit)
PrecomputeFeedService.new.call(@type, @account, limit)
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
end
end

View File

@@ -8,11 +8,11 @@ class Follow < ApplicationRecord
validates :account_id, uniqueness: { scope: :target_account_id }
def verb
self.destroyed? ? :unfollow : :follow
destroyed? ? :unfollow : :follow
end
def target
self.target_account
target_account
end
def object_type
@@ -20,6 +20,6 @@ class Follow < ApplicationRecord
end
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

View File

@@ -1,18 +1,18 @@
class MediaAttachment < ApplicationRecord
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif']
VIDEO_MIME_TYPES = ['video/webm']
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
VIDEO_MIME_TYPES = ['video/webm'].freeze
belongs_to :account, 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_size :file, less_than: 4.megabytes
validates :account, presence: true
def local?
self.remote_url.blank?
remote_url.blank?
end
def file_remote_url=(url)

View File

@@ -15,21 +15,22 @@ class Status < ApplicationRecord
validates :account, presence: true
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_includes, -> { includes(:account, :media_attachments, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) }
def local?
self.uri.nil?
uri.nil?
end
def reblog?
!self.reblog_of_id.nil?
!reblog_of_id.nil?
end
def reply?
!self.in_reply_to_id.nil?
!in_reply_to_id.nil?
end
def verb
@@ -41,11 +42,11 @@ class Status < ApplicationRecord
end
def content
reblog? ? self.reblog.text : self.text
reblog? ? reblog.text : text
end
def target
self.reblog
reblog
end
def title
@@ -53,33 +54,33 @@ class Status < ApplicationRecord
end
def reblogs_count
self.attributes['reblogs_count'] || self.reblogs.count
attributes['reblogs_count'] || reblogs.count
end
def favourites_count
self.attributes['favourites_count'] || self.favourites.count
attributes['favourites_count'] || favourites.count
end
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)
ids.map { |id| statuses[id].first }
end
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)
ids.map { |id| statuses[id].first }
end
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
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
def self.favourites_map(status_ids, account_id)
@@ -87,10 +88,10 @@ class Status < ApplicationRecord
end
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
before_validation do
self.text.strip!
text.strip!
end
end

View File

@@ -10,16 +10,16 @@ class StreamEntry < ApplicationRecord
validates :account, :activity, presence: true
STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]]
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]) }
def object_type
orphaned? ? :activity : (targeted? ? :activity : self.activity.object_type)
orphaned? ? :activity : (targeted? ? :activity : activity.object_type)
end
def verb
orphaned? ? :delete : self.activity.verb
orphaned? ? :delete : activity.verb
end
def targeted?
@@ -27,15 +27,15 @@ class StreamEntry < ApplicationRecord
end
def target
orphaned? ? nil : self.activity.target
orphaned? ? nil : activity.target
end
def title
orphaned? ? nil : self.activity.title
orphaned? ? nil : activity.title
end
def content
orphaned? ? nil : self.activity.content
orphaned? ? nil : activity.content
end
def threaded?
@@ -43,20 +43,20 @@ class StreamEntry < ApplicationRecord
end
def thread
orphaned? ? nil : self.activity.thread
orphaned? ? nil : activity.thread
end
def mentions
self.activity.respond_to?(:mentions) ? self.activity.mentions.map { |x| x.account } : []
activity.respond_to?(:mentions) ? activity.mentions.map(&:account) : []
end
def activity
self.send(self.activity_type.downcase.to_sym)
send(activity_type.downcase.to_sym)
end
private
def orphaned?
self.activity.nil?
activity.nil?
end
end

View File

@@ -1,14 +1,12 @@
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
accepts_nested_attributes_for :account
validates :account, presence: true
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner
def admin?
self.admin
end
scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
scope :recent, -> { order('created_at desc') }
scope :admins, -> { where(admin: true) }
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)
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)
end
end
@@ -23,7 +23,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_mentioned(status)
status.mentions.each do |mention|
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)
end
end

View File

@@ -5,10 +5,10 @@ class FavouriteService < BaseService
# @return [Favourite]
def call(account, 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?
NotificationMailer.favourite(status, account).deliver_later
NotificationMailer.favourite(status, account).deliver_later unless status.account.blocking?(account)
else
NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id)
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
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 response.code != 200
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
return nil if atom_url.nil?
return process_atom(atom_url, body)
end
private
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)
account = extract_author(url, xml)
return nil if account.nil?
statuses = ProcessFeedService.new.(body, account)
statuses = ProcessFeedService.new.call(body, account)
return statuses.first
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)
url_parts = Addressable::URI.parse(url)
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}"
return FollowRemoteAccountService.new.("#{username}@#{domain}")
end
def fetch(url)
http_client.get(url).to_s
end
def http_client
HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50)
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
rescue Nokogiri::XML::XPath::SyntaxError
Rails.logger.debug "Invalid XML or missing namespace"
end
end

View File

@@ -28,11 +28,11 @@ class FollowRemoteAccountService < BaseService
hubs = feed.xpath('//xmlns:link[@rel="hub"]')
if hubs.empty? || hubs.first.attribute('href').nil?
raise Goldfinger::Error, "No PubSubHubbub hubs found"
raise Goldfinger::Error, 'No PubSubHubbub hubs found'
end
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
account.uri = feed.at_xpath('/xmlns:feed/xmlns:author/xmlns:uri').content
@@ -53,12 +53,12 @@ class FollowRemoteAccountService < BaseService
def get_profile(xml, account)
author = xml.at_xpath('/xmlns:feed/xmlns:author')
update_remote_profile_service.(author, account)
update_remote_profile_service.call(author, account)
end
def magic_key_to_pem(magic_key)
_, 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.n = modulus
@@ -75,4 +75,3 @@ class FollowRemoteAccountService < BaseService
HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50)
end
end

View File

@@ -3,21 +3,21 @@ class FollowService < BaseService
# @param [Account] source_account From which to follow
# @param [String] uri User URI to follow in the form of username@domain
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
follow = source_account.follow!(target_account)
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
subscribe_service.(target_account)
subscribe_service.call(target_account)
NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
end
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
end

View File

@@ -8,9 +8,9 @@ class PostStatusService < BaseService
def call(account, text, in_reply_to = nil, media_ids = nil)
status = account.statuses.create!(text: text, thread: in_reply_to)
attach_media(status, media_ids)
process_mentions_service.(status)
process_mentions_service.call(status)
DistributionWorker.perform_async(status.id)
account.ping!(account_url(account, format: 'atom'), [Rails.configuration.x.hub_url])
HubPingWorker.perform_async(account.id)
status
end
@@ -19,7 +19,7 @@ class PostStatusService < BaseService
def attach_media(status, media_ids)
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)
end

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