Compare commits
49 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9712d8ffb3 | ||
|
3319473b2c | ||
|
2febc6ed65 | ||
|
f254da17f6 | ||
|
fe77921e47 | ||
|
b078885414 | ||
|
feda1e6554 | ||
|
70e9dd0b5b | ||
|
7b9a4af311 | ||
|
2c9e672ee2 | ||
|
9d59d7b463 | ||
|
a488b05726 | ||
|
fc198a8b4c | ||
|
b3c7c8700d | ||
|
188c6f326b | ||
|
d74da1a89a | ||
|
f06f295890 | ||
|
9fd3d7b6cd | ||
|
a0f85774c4 | ||
|
6d7290f47c | ||
|
31a0202546 | ||
|
2d4f9ecebf | ||
|
be86d4e0a3 | ||
|
1fce687f8e | ||
|
4909bbf415 | ||
|
b36197c307 | ||
|
492224b93f | ||
|
96ad9b0db1 | ||
|
62b057b085 | ||
|
ef2b50c9ac | ||
|
a41c3487bd | ||
|
a4f7eca5fa | ||
|
927333f4f8 | ||
|
e4aebad35a | ||
|
2603c7e69f | ||
|
e63aebff7a | ||
|
45a96e899e | ||
|
4f9b7432dd | ||
|
3f75f52285 | ||
|
7e14eefc81 | ||
|
210362e665 | ||
|
c6b0311b86 | ||
|
0bd4608ad1 | ||
|
693383234c | ||
|
e9bc4a4a08 | ||
|
62b384824d | ||
|
15f51dbf8c | ||
|
fc7b982b67 | ||
|
1215ab57ff |
14
.rubocop.yml
Normal file
14
.rubocop.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
Rails:
|
||||
Enabled: true
|
||||
|
||||
Metrics/LineLength:
|
||||
Enabled: false
|
||||
|
||||
Style/PerlBackrefs:
|
||||
AutoCorrect: false
|
||||
|
||||
Style/ClassAndModuleChildren:
|
||||
Enabled: false
|
||||
|
||||
Documentation:
|
||||
Enabled: false
|
1
Gemfile
1
Gemfile
@@ -19,6 +19,7 @@ gem 'paperclip', '~> 4.3'
|
||||
gem 'paperclip-av-transcoder'
|
||||
|
||||
gem 'http'
|
||||
gem 'httplog'
|
||||
gem 'addressable'
|
||||
gem 'nokogiri'
|
||||
gem 'link_header'
|
||||
|
16
Gemfile.lock
16
Gemfile.lock
@@ -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
|
||||
|
43
README.md
43
README.md
@@ -9,6 +9,12 @@ Mastodon
|
||||
|
||||
Mastodon is a federated microblogging engine. An alternative implementation of the GNU social project. Based on ActivityStreams, Webfinger, PubsubHubbub and Salmon.
|
||||
|
||||
Click on the screenshot to watch a demo of the UI:
|
||||
|
||||
[][youtube_demo]
|
||||
|
||||
[youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU
|
||||
|
||||
Focus of the project on a clean REST API and a good user interface. Ruby on Rails is used for the back-end, while React.js and Redux are used for the dynamic front-end. A static front-end for public resources (profiles and statuses) is also provided.
|
||||
|
||||
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.
|
||||
|
BIN
app/assets/images/background-photo.jpeg
Normal file
BIN
app/assets/images/background-photo.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
Before Width: | Height: | Size: 1.3 MiB |
BIN
app/assets/images/logo.png
Normal file
BIN
app/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
@@ -15,6 +15,14 @@ export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
|
||||
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
|
||||
export const ACCOUNT_UNFOLLOW_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
|
||||
};
|
||||
};
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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));
|
||||
});
|
||||
|
@@ -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
|
||||
};
|
||||
};
|
||||
|
@@ -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
|
||||
};
|
||||
};
|
||||
|
@@ -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));
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -0,0 +1,69 @@
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from './icon_button';
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||
|
||||
const StatusActionBar = React.createClass({
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: React.PropTypes.func,
|
||||
onFavourite: React.PropTypes.func,
|
||||
onReblog: React.PropTypes.func,
|
||||
onDelete: React.PropTypes.func
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleReplyClick () {
|
||||
this.props.onReply(this.props.status);
|
||||
},
|
||||
|
||||
handleFavouriteClick () {
|
||||
this.props.onFavourite(this.props.status);
|
||||
},
|
||||
|
||||
handleReblogClick () {
|
||||
this.props.onReblog(this.props.status);
|
||||
},
|
||||
|
||||
handleDeleteClick(e) {
|
||||
e.preventDefault();
|
||||
this.props.onDelete(this.props.status);
|
||||
},
|
||||
|
||||
render () {
|
||||
const { status, me } = this.props;
|
||||
let menu = '';
|
||||
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
menu = (
|
||||
<ul>
|
||||
<li><a href='#' onClick={this.handleDeleteClick}>Delete</a></li>
|
||||
</ul>
|
||||
);
|
||||
} else {
|
||||
menu = <ul />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '10px', overflow: 'hidden' }}>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title='Reply' icon='reply' onClick={this.handleReplyClick} /></div>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
|
||||
<div onClick={e => e.stopPropagation()} style={{ width: '18px', height: '18px', float: 'left' }}>
|
||||
<Dropdown>
|
||||
<DropdownTrigger className='icon-button' style={{ fontSize: '18px', lineHeight: '18px', width: '18px', height: '18px' }}>
|
||||
<i className='fa fa-fw fa-ellipsis-h' />
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent>{menu}</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default StatusActionBar;
|
@@ -26,7 +26,7 @@ const StatusContent = React.createClass({
|
||||
} else {
|
||||
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();
|
||||
},
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -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>
|
||||
);
|
||||
},
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -0,0 +1,30 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const ActionBar = React.createClass({
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: React.PropTypes.func.isRequired,
|
||||
onReblog: React.PropTypes.func.isRequired,
|
||||
onFavourite: React.PropTypes.func.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
render () {
|
||||
const { status } = this.props;
|
||||
|
||||
return (
|
||||
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
|
||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title='Reply' icon='reply' onClick={() => this.props.onReply(status)} /></div>
|
||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title='Reblog' icon='retweet' onClick={() => this.props.onReblog(status)} /></div>
|
||||
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title='Favourite' icon='star' onClick={() => this.props.onFavourite(status)} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default ActionBar;
|
@@ -0,0 +1,63 @@
|
||||
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import VideoPlayer from '../../../components/video_player';
|
||||
import moment from 'moment';
|
||||
|
||||
const DetailedStatus = React.createClass({
|
||||
|
||||
contextTypes: {
|
||||
router: React.PropTypes.object
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
status: ImmutablePropTypes.map.isRequired
|
||||
},
|
||||
|
||||
mixins: [PureRenderMixin],
|
||||
|
||||
handleAccountClick (e) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
},
|
||||
|
||||
render () {
|
||||
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
|
||||
let media = '';
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
|
||||
} else {
|
||||
media = <MediaGallery media={status.get('media_attachments')} height={300} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: '#2f3441', padding: '14px 10px' }} className='detailed-status'>
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
|
||||
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div>
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
|
||||
<StatusContent status={status} />
|
||||
|
||||
{media}
|
||||
|
||||
<div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
|
||||
<a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'>{moment(status.get('created_at')).format('HH:mm, DD MMM Y')}</a> · <i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('reblogs_count')}</span> · <i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}>{status.get('favourites_count')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default DetailedStatus;
|
@@ -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>
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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 () {
|
||||
|
@@ -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']))
|
||||
};
|
||||
};
|
||||
|
@@ -15,8 +15,8 @@ const mapDispatchToProps = function (dispatch) {
|
||||
dispatch(changeFollow(text));
|
||||
},
|
||||
|
||||
onSubmit: function () {
|
||||
dispatch(submitFollow());
|
||||
onSubmit: function (router) {
|
||||
dispatch(submitFollow(router));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -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')));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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));
|
||||
|
47
app/assets/stylesheets/about.scss
Normal file
47
app/assets/stylesheets/about.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
@import url(https://fonts.googleapis.com/css?family=Montserrat);
|
||||
@import url(https://fonts.googleapis.com/css?family=Judson);
|
||||
|
||||
.about-body {
|
||||
.wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
color: #9baec8;
|
||||
padding-top: 200px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font: 46px/52px 'Roboto', sans-serif;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: #2b90d9;
|
||||
padding: 20px 0;
|
||||
|
||||
img {
|
||||
margin-bottom: -5px;
|
||||
margin-right: 5px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font: 20px/28px 'Judson', sans-serif;
|
||||
font-weight: 300;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
em {
|
||||
display: inline-block;
|
||||
padding: 7px 7px 5px 7px;
|
||||
background: #9baec8;
|
||||
color: #282c37;
|
||||
font: 16px/16px 'Montserrat', sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px) {
|
||||
.wrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,6 +8,11 @@
|
||||
overflow: hidden;
|
||||
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;
|
||||
|
@@ -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';
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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}"
|
||||
|
5
app/controllers/about_controller.rb
Normal file
5
app/controllers/about_controller.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AboutController < ApplicationController
|
||||
def index
|
||||
@body_classes = 'about-body'
|
||||
end
|
||||
end
|
@@ -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
|
||||
|
@@ -1,13 +0,0 @@
|
||||
class Api::FollowsController < ApiController
|
||||
before_action :doorkeeper_authorize!
|
||||
respond_to :json
|
||||
|
||||
def create
|
||||
if params[:uri].blank?
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
@account = FollowService.new.(current_user.account, params[:uri]).try(:target_account)
|
||||
render action: :show
|
||||
end
|
||||
end
|
@@ -3,7 +3,7 @@ class Api::SalmonController < ApiController
|
||||
respond_to :txt
|
||||
|
||||
def update
|
||||
ProcessInteractionService.new.(request.body.read, @account)
|
||||
ProcessInteractionService.new.call(request.body.read, @account)
|
||||
head 201
|
||||
end
|
||||
|
||||
|
@@ -1,37 +0,0 @@
|
||||
class Api::StatusesController < ApiController
|
||||
before_action :doorkeeper_authorize!
|
||||
respond_to :json
|
||||
|
||||
def show
|
||||
@status = Status.find(params[:id])
|
||||
end
|
||||
|
||||
def context
|
||||
@status = Status.find(params[:id])
|
||||
@ancestors = @status.ancestors
|
||||
@descendants = @status.descendants
|
||||
end
|
||||
|
||||
def create
|
||||
@status = PostStatusService.new.(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), params[:media_ids])
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def reblog
|
||||
@status = ReblogService.new.(current_user.account, Status.find(params[:id])).reload
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def favourite
|
||||
@status = FavouriteService.new.(current_user.account, Status.find(params[:id])).status.reload
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def home
|
||||
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id]).to_a
|
||||
end
|
||||
|
||||
def mentions
|
||||
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id]).to_a
|
||||
end
|
||||
end
|
@@ -4,7 +4,7 @@ class Api::SubscriptionsController < ApiController
|
||||
|
||||
def show
|
||||
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
|
||||
|
@@ -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
|
7
app/controllers/api/v1/apps_controller.rb
Normal file
7
app/controllers/api/v1/apps_controller.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Api::V1::AppsController < ApiController
|
||||
respond_to :json
|
||||
|
||||
def create
|
||||
@app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris])
|
||||
end
|
||||
end
|
11
app/controllers/api/v1/follows_controller.rb
Normal file
11
app/controllers/api/v1/follows_controller.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class Api::V1::FollowsController < ApiController
|
||||
before_action :doorkeeper_authorize!
|
||||
respond_to :json
|
||||
|
||||
def create
|
||||
raise ActiveRecord::RecordNotFound if params[:uri].blank?
|
||||
|
||||
@account = FollowService.new.call(current_user.account, params[:uri]).try(:target_account)
|
||||
render action: :show
|
||||
end
|
||||
end
|
@@ -1,4 +1,4 @@
|
||||
class Api::MediaController < ApiController
|
||||
class Api::V1::MediaController < ApiController
|
||||
before_action :doorkeeper_authorize!
|
||||
respond_to :json
|
||||
|
54
app/controllers/api/v1/statuses_controller.rb
Normal file
54
app/controllers/api/v1/statuses_controller.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class Api::V1::StatusesController < ApiController
|
||||
before_action :doorkeeper_authorize!
|
||||
respond_to :json
|
||||
|
||||
def show
|
||||
@status = Status.find(params[:id])
|
||||
end
|
||||
|
||||
def context
|
||||
@status = Status.find(params[:id])
|
||||
@ancestors = @status.ancestors
|
||||
@descendants = @status.descendants
|
||||
end
|
||||
|
||||
def create
|
||||
@status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), params[:media_ids])
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def destroy
|
||||
@status = Status.where(account_id: current_user.account).find(params[:id])
|
||||
RemoveStatusService.new.call(@status)
|
||||
render_empty
|
||||
end
|
||||
|
||||
def reblog
|
||||
@status = ReblogService.new.call(current_user.account, Status.find(params[:id])).reload
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def unreblog
|
||||
RemoveStatusService.new.call(Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!)
|
||||
@status = Status.find(params[:id])
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def favourite
|
||||
@status = FavouriteService.new.call(current_user.account, Status.find(params[:id])).status.reload
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def unfavourite
|
||||
@status = UnfavouriteService.new.call(current_user.account, Status.find(params[:id])).status.reload
|
||||
render action: :show
|
||||
end
|
||||
|
||||
def home
|
||||
@statuses = Feed.new(:home, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
||||
end
|
||||
|
||||
def mentions
|
||||
@statuses = Feed.new(:mentions, current_user.account).get(20, params[:max_id], params[:since_id]).to_a
|
||||
end
|
||||
end
|
@@ -2,8 +2,8 @@ class ApiController < ApplicationController
|
||||
protect_from_forgery with: :null_session
|
||||
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
|
||||
|
@@ -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
|
||||
|
3
app/controllers/auth/confirmations_controller.rb
Normal file
3
app/controllers/auth/confirmations_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Auth::ConfirmationsController < Devise::ConfirmationsController
|
||||
layout 'auth'
|
||||
end
|
@@ -1,13 +1,13 @@
|
||||
class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -1,18 +0,0 @@
|
||||
class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
|
||||
before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
@applications = current_user.oauth_applications
|
||||
end
|
||||
|
||||
def create
|
||||
@application = Doorkeeper::Application.new(application_params)
|
||||
@application.owner = current_user
|
||||
|
||||
if @application.save
|
||||
redirect_to oauth_application_url(@application)
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
end
|
@@ -1,4 +1,6 @@
|
||||
class SettingsController < ApplicationController
|
||||
layout 'auth'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_account
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
2
app/helpers/about_helper.rb
Normal file
2
app/helpers/about_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module AboutHelper
|
||||
end
|
@@ -1,5 +1,10 @@
|
||||
module AccountsHelper
|
||||
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
|
||||
|
@@ -1,2 +0,0 @@
|
||||
module Api::AccountsHelper
|
||||
end
|
@@ -1,2 +0,0 @@
|
||||
module Api::FollowsHelper
|
||||
end
|
@@ -1,2 +0,0 @@
|
||||
module Api::MediaHelper
|
||||
end
|
@@ -1,2 +0,0 @@
|
||||
module Api::SalmonHelper
|
||||
end
|
@@ -1,2 +0,0 @@
|
||||
module Api::StatusesHelper
|
||||
end
|
@@ -1,2 +0,0 @@
|
||||
module Api::SubscriptionsHelper
|
||||
end
|
@@ -200,6 +200,6 @@ module AtomBuilderHelper
|
||||
end
|
||||
|
||||
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
|
||||
|
@@ -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
|
||||
|
@@ -1,2 +0,0 @@
|
||||
module Oauth::ApplicationsHelper
|
||||
end
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
7
app/models/block.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Block < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
validates :account, :target_account, presence: true
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
end
|
@@ -2,6 +2,11 @@ module Paginable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
25
app/services/block_service.rb
Normal file
25
app/services/block_service.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class BlockService < BaseService
|
||||
def call(account, target_account)
|
||||
return if account.id == target_account.id
|
||||
|
||||
UnfollowService.new.call(account, target_account) if account.following?(target_account)
|
||||
account.block!(target_account)
|
||||
clear_mentions(account, target_account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clear_mentions(account, target_account)
|
||||
timeline_key = FeedManager.instance.key(:mentions, account.id)
|
||||
|
||||
target_account.statuses.select('id').find_each do |status|
|
||||
redis.zrem(timeline_key, status.id)
|
||||
end
|
||||
|
||||
FeedManager.instance.broadcast(account.id, type: 'block', id: target_account.id)
|
||||
end
|
||||
|
||||
def redis
|
||||
$redis
|
||||
end
|
||||
end
|
@@ -15,7 +15,7 @@ class FanOutOnWriteService < BaseService
|
||||
|
||||
def deliver_to_followers(status)
|
||||
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
|
||||
|
@@ -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
|
||||
|
49
app/services/fetch_atom_service.rb
Normal file
49
app/services/fetch_atom_service.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
class FetchAtomService < BaseService
|
||||
def call(url)
|
||||
response = http_client.head(url)
|
||||
|
||||
Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
|
||||
return nil if response.code != 200
|
||||
|
||||
if response.mime_type == 'application/atom+xml'
|
||||
return [url, fetch(url)]
|
||||
elsif !response['Link'].blank?
|
||||
return process_headers(url, response)
|
||||
else
|
||||
return process_html(fetch(url))
|
||||
end
|
||||
|
||||
rescue OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.debug "SSL error: #{e}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_html(body)
|
||||
Rails.logger.debug 'Processing HTML'
|
||||
|
||||
page = Nokogiri::HTML(body)
|
||||
alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
|
||||
|
||||
return nil if alternate_link.nil?
|
||||
return [alternate_link['href'], fetch(alternate_link['href'])]
|
||||
end
|
||||
|
||||
def process_headers(url, response)
|
||||
Rails.logger.debug 'Processing link header'
|
||||
|
||||
link_header = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
|
||||
alternate_link = link_header.find_link(['rel', 'alternate'], ['type', 'application/atom+xml'])
|
||||
|
||||
return process_html(fetch(url)) if alternate_link.nil?
|
||||
return [alternate_link.href, fetch(alternate_link.href)]
|
||||
end
|
||||
|
||||
def fetch(url)
|
||||
http_client.get(url).to_s
|
||||
end
|
||||
|
||||
def http_client
|
||||
HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50).follow
|
||||
end
|
||||
end
|
@@ -1,17 +0,0 @@
|
||||
class FetchFeedService < BaseService
|
||||
# Fetch an account's feed and process it
|
||||
# @param [Account] account
|
||||
def call(account)
|
||||
process_service.(http_client.get(account.remote_url), account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_service
|
||||
@process_service ||= ProcessFeedService.new
|
||||
end
|
||||
|
||||
def http_client
|
||||
HTTP
|
||||
end
|
||||
end
|
25
app/services/fetch_remote_account_service.rb
Normal file
25
app/services/fetch_remote_account_service.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class FetchRemoteAccountService < BaseService
|
||||
def call(url)
|
||||
atom_url, body = FetchAtomService.new.call(url)
|
||||
|
||||
return nil if atom_url.nil?
|
||||
return process_atom(atom_url, body)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_atom(url, body)
|
||||
xml = Nokogiri::XML(body)
|
||||
url_parts = Addressable::URI.parse(url)
|
||||
username = xml.at_xpath('//xmlns:author/xmlns:name').try(:content)
|
||||
domain = url_parts.host
|
||||
|
||||
return nil if username.nil?
|
||||
|
||||
Rails.logger.debug "Going to webfinger #{username}@#{domain}"
|
||||
|
||||
return FollowRemoteAccountService.new.call("#{username}@#{domain}")
|
||||
rescue Nokogiri::XML::XPath::SyntaxError
|
||||
Rails.logger.debug "Invalid XML or missing namespace"
|
||||
end
|
||||
end
|
@@ -1,54 +1,26 @@
|
||||
class FetchRemoteStatusService < BaseService
|
||||
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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
Reference in New Issue
Block a user