diff --git a/.env.production.sample b/.env.production.sample index fa1ea833..e1e50320 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -12,7 +12,7 @@ LOCAL_DOMAIN=example.com LOCAL_HTTPS=true # Application secrets -# Generate each with the `rake secret` task +# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) PAPERCLIP_SECRET= SECRET_KEY_BASE= diff --git a/.eslintrc b/.eslintrc index 10bf7054..f91385ce 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,7 +15,37 @@ "sourceType": "module", "ecmaFeatures": { - "jsx": true - }, + "arrowFunctions": true, + "jsx": true, + "destructuring": true, + "modules": true, + "spread": true + } }, + + "rules": { + "no-cond-assign": 2, + "no-console": 1, + "no-irregular-whitespace": 2, + "no-unreachable": 2, + "valid-typeof": 2, + "consistent-return": 2, + "dot-notation": 2, + "eqeqeq": 2, + "no-fallthrough": 2, + "no-unused-expressions": 2, + "strict": 0, + "no-catch-shadow": 2, + "indent": [1, 2], + "brace-style": 1, + "comma-spacing": [1, {"before": false, "after": true}], + "comma-style": [1, "last"], + "no-mixed-spaces-and-tabs": 1, + "no-nested-ternary": 1, + "no-trailing-spaces": 1, + "react/wrap-multilines": 2, + "react/self-closing-comp": 2, + "react/prop-types": 2, + "react/no-multi-comp": 0 + } } diff --git a/.rubocop.yml b/.rubocop.yml index b973f01c..28c73591 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -86,3 +86,4 @@ AllCops: - 'config/**/*' - 'bin/*' - 'Rakefile' + - 'node_modules/**/*' diff --git a/Gemfile.lock b/Gemfile.lock index b01ac36e..2467b76c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,7 +39,8 @@ GEM i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) - addressable (2.4.0) + addressable (2.5.0) + public_suffix (~> 2.0, >= 2.0.2) arel (7.1.4) ast (2.3.0) autoprefixer-rails (6.5.0.2) @@ -98,7 +99,7 @@ GEM warden (~> 1.2.3) diff-lcs (1.2.5) docile (1.1.5) - domain_name (0.5.20160826) + domain_name (0.5.20161129) unf (>= 0.0.5, < 1.0.0) doorkeeper (4.2.0) railties (>= 4.2) @@ -121,7 +122,7 @@ GEM ruby-progressbar (~> 1.4) globalid (0.3.7) activesupport (>= 4.1.0) - goldfinger (1.1.0) + goldfinger (1.1.2) addressable (~> 2.4) http (~> 2.0) nokogiri (~> 1.6) @@ -138,7 +139,7 @@ GEM highline (1.7.8) hiredis (0.6.1) htmlentities (4.3.4) - http (2.0.3) + http (2.1.0) addressable (~> 2.3) http-cookie (~> 1.0) http-form_data (~> 1.0.1) @@ -226,6 +227,7 @@ GEM slop (~> 3.4) pry-rails (0.3.4) pry (>= 0.9.10) + public_suffix (2.0.4) puma (3.6.0) rabl (0.13.1) activesupport (>= 2.3.14) diff --git a/README.md b/README.md index 3add1047..2d84062a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ An alternative implementation of the GNU social project. Based on ActivityStream Click on the screenshot to watch a demo of the UI: -[![Screenshot](https://i.imgur.com/pNieDFp.png)][youtube_demo] +[![Screenshot](https://i.imgur.com/T2q5V65.png)][youtube_demo] [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU diff --git a/app/assets/images/mastodon-getting-started.png b/app/assets/images/mastodon-getting-started.png new file mode 100644 index 00000000..e05dd493 Binary files /dev/null and b/app/assets/images/mastodon-getting-started.png differ diff --git a/app/assets/images/screenshot.png b/app/assets/images/screenshot.png index 96446906..f248fd51 100644 Binary files a/app/assets/images/screenshot.png and b/app/assets/images/screenshot.png differ diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 759435af..8d28b051 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -51,6 +51,22 @@ export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; +export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + export function setAccountSelf(account) { return { type: ACCOUNT_SET_SELF, @@ -509,3 +525,140 @@ export function fetchRelationshipsFail(error) { error }; }; + +export function fetchFollowRequests() { + return (dispatch, getState) => { + dispatch(fetchFollowRequestsRequest()); + + api(getState).get('/api/v1/follow_requests').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)) + }).catch(error => dispatch(fetchFollowRequestsFail(error))); + }; +}; + +export function fetchFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_FETCH_REQUEST + }; +}; + +export function fetchFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next + }; +}; + +export function fetchFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_FETCH_FAIL, + error + }; +}; + +export function expandFollowRequests() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'follow_requests', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)) + }).catch(error => dispatch(expandFollowRequestsFail(error))); + }; +}; + +export function expandFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_EXPAND_REQUEST + }; +}; + +export function expandFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next + }; +}; + +export function expandFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error + }; +}; + +export function authorizeFollowRequest(id) { + return (dispatch, getState) => { + dispatch(authorizeFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(response => dispatch(authorizeFollowRequestSuccess(id))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; +}; + +export function authorizeFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id + }; +}; + +export function authorizeFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + id + }; +}; + +export function authorizeFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error + }; +}; + + +export function rejectFollowRequest(id) { + return (dispatch, getState) => { + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(response => dispatch(rejectFollowRequestSuccess(id))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; +}; + +export function rejectFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_REJECT_REQUEST, + id + }; +}; + +export function rejectFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_REJECT_SUCCESS, + id + }; +}; + +export function rejectFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error + }; +}; diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index 6a8b1b05..8bd83540 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -14,6 +14,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; +export const NOTIFICATIONS_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE'; + const fetchRelatedRelationships = (dispatch, notifications) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); @@ -23,7 +25,7 @@ const fetchRelatedRelationships = (dispatch, notifications) => { }; export function updateNotifications(notification, intlMessages, intlLocale) { - return dispatch => { + return (dispatch, getState) => { dispatch({ type: NOTIFICATIONS_UPDATE, notification, @@ -34,7 +36,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { fetchRelatedRelationships(dispatch, [notification]); // Desktop notifications - if (typeof window.Notification !== 'undefined') { + if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) { const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); const body = $('

').html(notification.status ? notification.status.content : '').text(); @@ -131,3 +133,11 @@ export function expandNotificationsFail(error) { error }; }; + +export function changeNotificationsSetting(key, checked) { + return { + type: NOTIFICATIONS_SETTING_CHANGE, + key, + checked + }; +}; diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx index 8d9da160..39ccbcaf 100644 --- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx +++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx @@ -32,6 +32,7 @@ const AutosuggestTextarea = React.createClass({ value: React.PropTypes.string, suggestions: ImmutablePropTypes.list, disabled: React.PropTypes.bool, + fileDropDate: React.PropTypes.instanceOf(Date), placeholder: React.PropTypes.string, onSuggestionSelected: React.PropTypes.func.isRequired, onSuggestionsClearRequested: React.PropTypes.func.isRequired, @@ -42,6 +43,8 @@ const AutosuggestTextarea = React.createClass({ getInitialState () { return { + isFileDragging: false, + fileDraggingDate: undefined, suggestionsHidden: false, selectedSuggestion: 0, lastToken: null, @@ -120,21 +123,51 @@ const AutosuggestTextarea = React.createClass({ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { this.setState({ suggestionsHidden: false }); } + + const fileDropDate = nextProps.fileDropDate; + const { isFileDragging, fileDraggingDate } = this.state; + + /* + * We can't detect drop events, because they might not be on the textarea (the app allows dropping anywhere in the + * window). Instead, on-drop, we notify this textarea to stop its hover effect by passing in a prop with the + * drop-date. + */ + if (isFileDragging && fileDraggingDate && fileDropDate // if dragging when props updated, and dates aren't undefined + && fileDropDate > fileDraggingDate) { // and if the drop date is now greater than when we started dragging + // then we should stop dragging + this.setState({ + isFileDragging: false + }); + } }, setTextarea (c) { this.textarea = c; }, + onDragEnter () { + this.setState({ + isFileDragging: true, + fileDraggingDate: new Date() + }) + }, + + onDragExit () { + this.setState({ + isFileDragging: false + }) + }, + render () { - const { value, suggestions, disabled, placeholder, onKeyUp } = this.props; - const { suggestionsHidden, selectedSuggestion } = this.state; + const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props; + const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state; + const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea'; return (