diff options
133 files changed, 1550 insertions, 664 deletions
diff --git a/.env.production.sample b/.env.production.sample index b68ba523c..52d519570 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -6,14 +6,13 @@ DB_USER=postgres DB_NAME=postgres DB_PASS= DB_PORT=5432 -NEO4J_HOST=neo4j -NEO4J_PORT=7474 # Federation LOCAL_DOMAIN=example.com LOCAL_HTTPS=true # Application secrets +# Generate each with the `rake secret` task PAPERCLIP_SECRET= SECRET_KEY_BASE= @@ -23,3 +22,13 @@ SMTP_PORT=587 SMTP_LOGIN= SMTP_PASSWORD= SMTP_FROM_ADDRESS=notifications@example.com + +# Optional asset host for multi-server setups +# CDN_HOST=assets.example.com + +# S3 (optional) +S3_ENABLED=false +S3_BUCKET= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +S3_REGION= diff --git a/.travis.yml b/.travis.yml index f6841779d..fe4549edd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,8 +11,6 @@ env: - LOCAL_DOMAIN=cb6e6126.ngrok.io - LOCAL_HTTPS=true - RAILS_ENV=test - - NEO4J_HOST=localhost - - NEO4J_PORT=7575 addons: postgresql: 9.4 diff --git a/Dockerfile.app b/Dockerfile index dfc0ad5b7..dfc0ad5b7 100644 --- a/Dockerfile.app +++ b/Dockerfile diff --git a/Dockerfile.neo4j b/Dockerfile.neo4j deleted file mode 100644 index 373c2abb0..000000000 --- a/Dockerfile.neo4j +++ /dev/null @@ -1,17 +0,0 @@ -FROM neo4j:latest - -ENV NEO4J_AUTH=none - -RUN cd /var/lib/neo4j/plugins \ - && wget http://products.graphaware.com/download/framework-server-community/graphaware-server-community-all-3.0.6.43.jar \ - && wget http://products.graphaware.com/download/noderank/graphaware-noderank-3.0.6.43.3.jar -RUN echo "dbms.unmanaged_extension_classes=com.graphaware.server=/graphaware" >> /var/lib/neo4j/conf/neo4j.conf -RUN echo 'com.graphaware.runtime.enabled=true\n\ -com.graphaware.module.NR.1=com.graphaware.module.noderank.NodeRankModuleBootstrapper\n\ -com.graphaware.module.NR.maxTopRankNodes=10\n\ -com.graphaware.module.NR.dampingFactor=0.85\n\ -com.graphaware.module.NR.propertyKey=nodeRank\n'\ - >> /var/lib/neo4j/conf/neo4j.conf -RUN echo 'com.graphaware.runtime.stats.disabled=true\n\ -com.graphaware.server.stats.disabled=true\n'\ - >> /var/lib/neo4j/conf/neo4j.conf diff --git a/Gemfile b/Gemfile index 9654c8828..95fd04629 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -gem 'rails', '5.0.0.1' +gem 'rails', git: 'https://github.com/rails/rails.git', branch: '5-0-stable' gem 'sass-rails', '~> 5.0' gem 'uglifier', '>= 1.3.0' gem 'coffee-rails', '~> 4.1.0' @@ -17,9 +17,9 @@ gem 'pghero' gem 'dotenv-rails' gem 'font-awesome-rails' -gem 'paperclip', '~> 4.3' +gem 'paperclip', '~> 5.0' gem 'paperclip-av-transcoder' -gem 'aws-sdk', '< 2.0' +gem 'aws-sdk', '>= 2.0' gem 'http' gem 'httplog' @@ -41,9 +41,9 @@ gem 'simple_form' gem 'will_paginate' gem 'rack-attack' gem 'rack-cors', require: 'rack/cors' +gem 'rack-timeout-puma' gem 'sidekiq' gem 'ledermann-rails-settings' -gem 'neography' gem 'pg_search' gem 'react-rails' diff --git a/Gemfile.lock b/Gemfile.lock index a31573af6..aa9f59da8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,5 +1,7 @@ -GEM - remote: https://rubygems.org/ +GIT + remote: https://github.com/rails/rails.git + revision: ecb394a31420f6fd1d0ab692c79f2dd44176e2c9 + branch: 5-0-stable specs: actioncable (5.0.0.1) actionpack (= 5.0.0.1) @@ -24,7 +26,6 @@ GEM erubis (~> 2.7.0) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - active_record_query_trace (1.5.3) activejob (5.0.0.1) activesupport (= 5.0.0.1) globalid (>= 0.3.6) @@ -39,18 +40,44 @@ GEM i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) + rails (5.0.0.1) + actioncable (= 5.0.0.1) + actionmailer (= 5.0.0.1) + actionpack (= 5.0.0.1) + actionview (= 5.0.0.1) + activejob (= 5.0.0.1) + activemodel (= 5.0.0.1) + activerecord (= 5.0.0.1) + activesupport (= 5.0.0.1) + bundler (>= 1.3.0, < 2.0) + railties (= 5.0.0.1) + sprockets-rails (>= 2.0.0) + railties (5.0.0.1) + actionpack (= 5.0.0.1) + activesupport (= 5.0.0.1) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) + +GEM + remote: https://rubygems.org/ + specs: + active_record_query_trace (1.5.3) addressable (2.4.0) - arel (7.1.1) + arel (7.1.4) ast (2.3.0) autoprefixer-rails (6.5.0.2) execjs av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (1.66.0) - aws-sdk-v1 (= 1.66.0) - aws-sdk-v1 (1.66.0) - json (~> 1.4) - nokogiri (>= 1.4.4) + aws-sdk (2.6.28) + aws-sdk-resources (= 2.6.28) + aws-sdk-core (2.6.28) + aws-sigv4 (~> 1.0) + jmespath (~> 1.0) + aws-sdk-resources (2.6.28) + aws-sdk-core (= 2.6.28) + aws-sigv4 (1.0.0) babel-source (5.8.35) babel-transpiler (0.7.0) babel-source (>= 4.0, < 6) @@ -108,7 +135,6 @@ GEM thread thread_safe erubis (2.7.0) - excon (0.53.0) execjs (2.7.0) fabrication (2.15.2) fast_blank (1.0.0) @@ -161,6 +187,7 @@ GEM jbuilder (2.6.0) activesupport (>= 3.0.0, < 5.1) multi_json (~> 1.2) + jmespath (1.3.1) jquery-rails (4.1.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) @@ -185,33 +212,25 @@ GEM mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) - mimemagic (0.3.0) + mimemagic (0.3.2) mini_portile2 (2.1.0) minitest (5.9.1) multi_json (1.12.1) - neography (1.8.0) - excon (>= 0.33.0) - json (>= 1.7.7) - multi_json (>= 1.3.2) - os (>= 0.9.6) - rake (>= 0.8.7) - rubyzip (>= 1.0.0) nio4r (1.2.1) nokogiri (1.6.8.1) mini_portile2 (~> 2.1.0) oj (2.17.3) orm_adapter (0.5.0) - os (0.9.6) ostatus2 (1.0.2) addressable (~> 2.4) http (~> 2.0) nokogiri (~> 1.6) - paperclip (4.3.7) - activemodel (>= 3.2.0) - activesupport (>= 3.2.0) + paperclip (5.1.0) + activemodel (>= 4.2.0) + activesupport (>= 4.2.0) cocaine (~> 0.5.5) mime-types - mimemagic (= 0.3.0) + mimemagic (~> 0.3.0) paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) @@ -242,18 +261,9 @@ GEM rack rack-test (0.6.3) rack (>= 1.0) - rails (5.0.0.1) - actioncable (= 5.0.0.1) - actionmailer (= 5.0.0.1) - actionpack (= 5.0.0.1) - actionview (= 5.0.0.1) - activejob (= 5.0.0.1) - activemodel (= 5.0.0.1) - activerecord (= 5.0.0.1) - activesupport (= 5.0.0.1) - bundler (>= 1.3.0, < 2.0) - railties (= 5.0.0.1) - sprockets-rails (>= 2.0.0) + rack-timeout (0.4.2) + rack-timeout-puma (0.0.1) + rack-timeout (~> 0.2, >= 0.2.0) rails-dom-testing (2.0.1) activesupport (>= 4.2.0, < 6.0) nokogiri (~> 1.6.0) @@ -266,12 +276,6 @@ GEM rails (> 3.1) rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.5) - railties (5.0.0.1) - actionpack (= 5.0.0.1) - activesupport (= 5.0.0.1) - method_source - rake (>= 0.8.7) - thor (>= 0.18.1, < 2.0) rainbow (2.1.0) rake (11.3.0) rdoc (4.2.2) @@ -333,7 +337,6 @@ GEM ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-progressbar (1.8.1) - rubyzip (1.2.0) safe_yaml (1.0.4) sass (3.4.22) sass-rails (5.0.6) @@ -362,7 +365,7 @@ GEM sprockets (3.7.0) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.1.1) + sprockets-rails (3.2.0) actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) @@ -403,7 +406,7 @@ DEPENDENCIES active_record_query_trace addressable autoprefixer-rails - aws-sdk (< 2.0) + aws-sdk (>= 2.0) better_errors binding_of_caller browserify-rails @@ -429,11 +432,10 @@ DEPENDENCIES letter_opener link_header lograge - neography nokogiri oj ostatus2 - paperclip (~> 4.3) + paperclip (~> 5.0) paperclip-av-transcoder pg pg_search @@ -443,7 +445,8 @@ DEPENDENCIES rabl rack-attack rack-cors - rails (= 5.0.0.1) + rack-timeout-puma + rails! rails_12factor rails_autolink react-rails diff --git a/README.md b/README.md index 25d179d86..00472a616 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,6 @@ Consult the example configuration file, `.env.production.sample` for the full li - PostgreSQL - Redis -- Neo4J (optional) - - GraphAware NodeRank ## Running with Docker and Docker-Compose @@ -90,8 +88,8 @@ The container has two volumes, for the assets and for user uploads. The default - `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 -- `rake mastodon:graphs:sync` re-imports all follow relationships into Neo4J. Only for troubleshooting +- `rake mastodon:feeds:clear_all` 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 +- `rake mastodon:feeds:clear` removes timelines of users who haven't signed in lately, which allows to save RAM and improve message distribution. This is required to be run periodically so that when they login again the regeneration process will trigger Running any of these tasks via docker-compose would look like this: @@ -117,6 +115,8 @@ Which will re-create the updated containers, leaving databases and data as is. D 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. +**IRC channel**: #mastodon on irc.freenode.net + ### Extra credits - The [Emoji One](https://github.com/Ranks/emojione) pack has been used for the emojis diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index 4a0777a64..759435afe 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -246,7 +246,8 @@ export function blockAccount(id) { dispatch(blockAccountRequest(id)); api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { - dispatch(blockAccountSuccess(response.data)); + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { dispatch(blockAccountFail(id, error)); }); @@ -272,10 +273,11 @@ export function blockAccountRequest(id) { }; }; -export function blockAccountSuccess(relationship) { +export function blockAccountSuccess(relationship, statuses) { return { type: ACCOUNT_BLOCK_SUCCESS, - relationship + relationship, + statuses }; }; diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index af3cdbf30..b97cb7b12 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -22,6 +22,8 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; +export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -62,7 +64,8 @@ export function submitCompose() { 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')) + media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), + sensitive: getState().getIn(['compose', 'sensitive']) }).then(function (response) { dispatch(submitComposeSuccess(response.data)); dispatch(updateTimeline('home', response.data)); @@ -197,3 +200,10 @@ export function unmountCompose() { type: COMPOSE_UNMOUNT }; }; + +export function changeComposeSensitivity(checked) { + return { + type: COMPOSE_SENSITIVITY_CHANGE, + checked + }; +}; diff --git a/app/assets/javascripts/components/actions/suggestions.jsx b/app/assets/javascripts/components/actions/suggestions.jsx deleted file mode 100644 index 6b3aa69dd..000000000 --- a/app/assets/javascripts/components/actions/suggestions.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import api from '../api'; - -export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; -export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; -export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; - -export function fetchSuggestions() { - return (dispatch, getState) => { - dispatch(fetchSuggestionsRequest()); - - api(getState).get('/api/v1/accounts/suggestions').then(response => { - dispatch(fetchSuggestionsSuccess(response.data)); - }).catch(error => { - dispatch(fetchSuggestionsFail(error)); - }); - }; -}; - -export function fetchSuggestionsRequest() { - return { - type: SUGGESTIONS_FETCH_REQUEST - }; -}; - -export function fetchSuggestionsSuccess(accounts) { - return { - type: SUGGESTIONS_FETCH_SUCCESS, - accounts: accounts - }; -}; - -export function fetchSuggestionsFail(error) { - return { - type: SUGGESTIONS_FETCH_FAIL, - error: error - }; -}; diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx index fe36d40c5..d63129013 100644 --- a/app/assets/javascripts/components/components/button.jsx +++ b/app/assets/javascripts/components/components/button.jsx @@ -7,7 +7,14 @@ const Button = React.createClass({ onClick: React.PropTypes.func, disabled: React.PropTypes.bool, block: React.PropTypes.bool, - secondary: React.PropTypes.bool + secondary: React.PropTypes.bool, + size: React.PropTypes.number, + }, + + getDefaultProps () { + return { + size: 36 + }; }, mixins: [PureRenderMixin], @@ -32,16 +39,16 @@ const Button = React.createClass({ fontWeight: '500', letterSpacing: '0', textTransform: 'uppercase', - padding: '0 16px', - height: '36px', + padding: `0 ${this.props.size / 2.25}px`, + height: `${this.props.size}px`, cursor: 'pointer', - lineHeight: '36px', + lineHeight: `${this.props.size}px`, borderRadius: '4px', textDecoration: 'none' }; - + return ( - <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={style}> + <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}> {this.props.text || this.props.children} </button> ); diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx index 537bab954..36f078a3a 100644 --- a/app/assets/javascripts/components/components/lightbox.jsx +++ b/app/assets/javascripts/components/components/lightbox.jsx @@ -43,13 +43,15 @@ const Lightbox = React.createClass({ render () { const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props; + const content = isVisible ? children : <div />; + return ( <div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}> <Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}> {({ y }) => <div style={{...dialogStyle, transform: `translateY(${y}px)`}}> <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} /> - {children} + {content} </div> } </Motion> diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx index bdb456a08..d04c7c869 100644 --- a/app/assets/javascripts/components/components/media_gallery.jsx +++ b/app/assets/javascripts/components/components/media_gallery.jsx @@ -1,9 +1,47 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import { FormattedMessage } from 'react-intl'; + +const outerStyle = { + marginTop: '8px', + overflow: 'hidden', + width: '100%', + boxSizing: 'border-box' +}; + +const spoilerStyle = { + background: '#000', + color: '#fff', + textAlign: 'center', + height: '100%', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column' +}; + +const spoilerSpanStyle = { + display: 'block', + fontSize: '14px', +}; + +const spoilerSubSpanStyle = { + display: 'block', + fontSize: '11px', + fontWeight: '500' +}; const MediaGallery = React.createClass({ + getInitialState () { + return { + visible: false + }; + }, + propTypes: { + sensitive: React.PropTypes.bool, media: ImmutablePropTypes.list.isRequired, height: React.PropTypes.number.isRequired, onOpenMedia: React.PropTypes.func.isRequired @@ -20,69 +58,85 @@ const MediaGallery = React.createClass({ e.stopPropagation(); }, + handleOpen () { + this.setState({ visible: true }); + }, + render () { - var children = this.props.media.take(4); - var size = children.size; - - children = children.map((attachment, i) => { - let width = 50; - let height = 100; - let top = 'auto'; - let left = 'auto'; - let bottom = 'auto'; - let right = 'auto'; - - if (size === 1) { - width = 100; - } - - if (size === 4 || (size === 3 && i > 0)) { - height = 50; - } - - if (size === 2) { - if (i === 0) { - right = '2px'; - } else { - left = '2px'; - } - } else if (size === 3) { - if (i === 0) { - right = '2px'; - } else if (i > 0) { - left = '2px'; - } + const { media, sensitive } = this.props; - if (i === 1) { - bottom = '2px'; - } else if (i > 1) { - top = '2px'; - } - } else if (size === 4) { - if (i === 0 || i === 2) { - right = '2px'; + let children; + + if (sensitive && !this.state.visible) { + children = ( + <div style={spoilerStyle} onClick={this.handleOpen}> + <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + const size = media.take(4).size; + + children = media.take(4).map((attachment, i) => { + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; } - if (i === 1 || i === 3) { - left = '2px'; + if (size === 4 || (size === 3 && i > 0)) { + height = 50; } - if (i < 2) { - bottom = '2px'; - } else { - top = '2px'; + if (size === 2) { + if (i === 0) { + right = '2px'; + } else { + left = '2px'; + } + } else if (size === 3) { + if (i === 0) { + right = '2px'; + } else if (i > 0) { + left = '2px'; + } + + if (i === 1) { + bottom = '2px'; + } else if (i > 1) { + top = '2px'; + } + } else if (size === 4) { + if (i === 0 || i === 2) { + right = '2px'; + } + + if (i === 1 || i === 3) { + left = '2px'; + } + + if (i < 2) { + bottom = '2px'; + } else { + top = '2px'; + } } - } - return ( - <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}> - <a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} /> - </div> - ); - }); + return ( + <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}> + <a href={attachment.get('url')} onClick={this.handleClick.bind(this, attachment.get('url'))} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} /> + </div> + ); + }); + } return ( - <div style={{ marginTop: '8px', overflow: 'hidden', width: '100%', height: `${this.props.height}px`, boxSizing: 'border-box' }}> + <div style={{ ...outerStyle, height: `${this.props.height}px` }}> {children} </div> ); diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx index 84cd07527..df5f0f2c2 100644 --- a/app/assets/javascripts/components/components/status.jsx +++ b/app/assets/javascripts/components/components/status.jsx @@ -34,6 +34,7 @@ const Status = React.createClass({ onReblog: React.PropTypes.func, onDelete: React.PropTypes.func, onOpenMedia: React.PropTypes.func, + onBlock: React.PropTypes.func, me: React.PropTypes.number, muted: React.PropTypes.bool }, @@ -81,11 +82,11 @@ const Status = React.createClass({ ); } - if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').size > 0 && !this.props.muted) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - media = <VideoPlayer media={status.getIn(['media_attachments', 0])} />; + media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />; } else { - media = <MediaGallery media={status.get('media_attachments')} height={110} onOpenMedia={this.props.onOpenMedia} />; + media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />; } } diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index dec1decff..35feda88b 100644 --- a/app/assets/javascripts/components/components/status_action_bar.jsx +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, mention: { id: 'status.mention', defaultMessage: 'Mention' }, + block: { id: 'account.block', defaultMessage: 'Block' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Reblog' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' } @@ -24,7 +25,8 @@ const StatusActionBar = React.createClass({ onFavourite: React.PropTypes.func, onReblog: React.PropTypes.func, onDelete: React.PropTypes.func, - onMention: React.PropTypes.func + onMention: React.PropTypes.func, + onBlock: React.PropTypes.func }, mixins: [PureRenderMixin], @@ -49,6 +51,10 @@ const StatusActionBar = React.createClass({ this.props.onMention(this.props.status.get('account')); }, + handleBlockClick () { + this.props.onBlock(this.props.status.get('account')); + }, + render () { const { status, me, intl } = this.props; let menu = []; @@ -57,6 +63,7 @@ const StatusActionBar = React.createClass({ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick }); } return ( diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx index 9b9b0a2e4..61c1995a7 100644 --- a/app/assets/javascripts/components/components/video_player.jsx +++ b/app/assets/javascripts/components/components/video_player.jsx @@ -1,7 +1,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import IconButton from './icon_button'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' } @@ -25,6 +25,30 @@ const muteStyle = { zIndex: '5' }; +const spoilerStyle = { + marginTop: '8px', + background: '#000', + color: '#fff', + textAlign: 'center', + height: '100%', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column' +}; + +const spoilerSpanStyle = { + display: 'block', + fontSize: '14px' +}; + +const spoilerSubSpanStyle = { + display: 'block', + fontSize: '11px', + fontWeight: '500' +}; + const VideoPlayer = React.createClass({ propTypes: { media: ImmutablePropTypes.map.isRequired, @@ -41,6 +65,7 @@ const VideoPlayer = React.createClass({ getInitialState () { return { + visible: false, muted: true }; }, @@ -63,8 +88,21 @@ const VideoPlayer = React.createClass({ } }, + handleOpen () { + this.setState({ visible: true }); + }, + render () { - const { media, intl, width, height } = this.props; + const { media, intl, width, height, sensitive } = this.props; + + if (sensitive && !this.state.visible) { + return ( + <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}> + <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } return ( <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}> diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 87c7c65f3..c42582bfd 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -39,6 +39,8 @@ import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; import es from 'react-intl/locale-data/es'; import fr from 'react-intl/locale-data/fr'; +import pt from 'react-intl/locale-data/pt'; +import hu from 'react-intl/locale-data/hu'; import getMessagesForLocale from '../locales'; const store = configureStore(); @@ -47,7 +49,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({ basename: '/web' }); -addLocaleData([...en, ...de, ...es, ...fr]); +addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu]); const Mastodon = React.createClass({ @@ -75,11 +77,6 @@ const Mastodon = React.createClass({ return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message))); case 'delete': return store.dispatch(deleteFromTimelines(data.id)); - case 'merge': - case 'unmerge': - return store.dispatch(refreshTimeline('home', true)); - case 'block': - return store.dispatch(refreshTimeline('mentions', true)); case 'notification': return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale)); } diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index 28756b5ef..6a882eab4 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -1,18 +1,19 @@ -import { connect } from 'react-redux'; -import Status from '../components/status'; +import { connect } from 'react-redux'; +import Status from '../components/status'; import { makeGetStatus } from '../selectors'; import { replyCompose, mentionCompose -} from '../actions/compose'; +} from '../actions/compose'; import { reblog, favourite, unreblog, unfavourite -} from '../actions/interactions'; -import { deleteStatus } from '../actions/statuses'; -import { openMedia } from '../actions/modal'; +} from '../actions/interactions'; +import { blockAccount } from '../actions/accounts'; +import { deleteStatus } from '../actions/statuses'; +import { openMedia } from '../actions/modal'; import { createSelector } from 'reselect' const mapStateToProps = (state, props) => ({ @@ -91,6 +92,10 @@ const mapDispatchToProps = (dispatch) => ({ onOpenMedia (url) { dispatch(openMedia(url)); + }, + + onBlock (account) { + dispatch(blockAccount(account.get('id'))); } }); diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx index cd01de2e2..f09dea6ab 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -58,10 +58,8 @@ const ActionBar = React.createClass({ } else if (account.getIn(['relationship', 'blocking'])) { menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock }); } else if (account.getIn(['relationship', 'following'])) { - menu.push({ text: intl.formatMessage(messages.unfollow), action: this.props.onFollow }); menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); } else { - menu.push({ text: intl.formatMessage(messages.follow), action: this.props.onFollow }); menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock }); } diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index b3e9e2a9f..b890e15c1 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -2,22 +2,30 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import emojify from '../../../emoji'; import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, +}); const Header = React.createClass({ propTypes: { account: ImmutablePropTypes.map.isRequired, - me: React.PropTypes.number.isRequired + me: React.PropTypes.number.isRequired, + onFollow: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], render () { - const { account, me } = this.props; + const { account, me, intl } = this.props; let displayName = account.get('display_name'); let info = ''; + let actionBtn = ''; if (displayName.length === 0) { displayName = account.get('username'); @@ -27,11 +35,19 @@ const Header = React.createClass({ info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> } + if (me !== account.get('id')) { + actionBtn = ( + <div style={{ position: 'absolute', top: '10px', left: '20px' }}> + <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> + </div> + ); + } + const content = { __html: emojify(account.get('note')) }; const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; return ( - <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}> + <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}> <div style={{ background: 'rgba(47, 52, 65, 0.9)', padding: '20px 10px' }}> <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}> <div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}> @@ -45,6 +61,7 @@ const Header = React.createClass({ <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> {info} + {actionBtn} </div> </div> ); @@ -52,4 +69,4 @@ const Header = React.createClass({ }); -export default Header; +export default injectIntl(Header); diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx index 818979f8f..c2cc58bb2 100644 --- a/app/assets/javascripts/components/features/account/index.jsx +++ b/app/assets/javascripts/components/features/account/index.jsx @@ -87,9 +87,8 @@ const Account = React.createClass({ return ( <Column> <ColumnBackButton /> - <Header account={account} me={me} /> - - <ActionBar account={account} me={me} onFollow={this.handleFollow} onBlock={this.handleBlock} onMention={this.handleMention} /> + <Header account={account} me={me} onFollow={this.handleFollow} /> + <ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} /> {this.props.children} </Column> diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 32bdeaeca..b16731c05 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -8,7 +8,8 @@ import Autosuggest from 'react-autosuggest'; import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container'; import { debounce } from 'react-decoration'; import UploadButtonContainer from '../containers/upload_button_container'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -54,7 +55,8 @@ const textareaStyle = { padding: '10px', fontFamily: 'Roboto', fontSize: '14px', - margin: '0' + margin: '0', + resize: 'vertical' }; const renderInputComponent = inputProps => ( @@ -67,6 +69,7 @@ const ComposeForm = React.createClass({ text: React.PropTypes.string.isRequired, suggestion_token: React.PropTypes.string, suggestions: React.PropTypes.array, + sensitive: React.PropTypes.bool, is_submitting: React.PropTypes.bool, is_uploading: React.PropTypes.bool, in_reply_to: ImmutablePropTypes.map, @@ -75,7 +78,8 @@ const ComposeForm = React.createClass({ onCancelReply: React.PropTypes.func.isRequired, onClearSuggestions: React.PropTypes.func.isRequired, onFetchSuggestions: React.PropTypes.func.isRequired, - onSuggestionSelected: React.PropTypes.func.isRequired + onSuggestionSelected: React.PropTypes.func.isRequired, + onChangeSensitivity: React.PropTypes.func.isRequired }, mixins: [PureRenderMixin], @@ -139,6 +143,10 @@ const ComposeForm = React.createClass({ this.autosuggest = c; }, + handleChangeSensitivity (e) { + this.props.onChangeSensitivity(e.target.checked); + }, + render () { const { intl } = this.props; let replyArea = ''; @@ -178,6 +186,11 @@ const ComposeForm = React.createClass({ <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></div> <UploadButtonContainer style={{ paddingTop: '4px' }} /> </div> + + <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', marginTop: '10px', borderTop: '1px solid #616b86', paddingTop: '10px' }}> + <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} /> + <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark content as sensitive' /></span> + </label> </div> ); } diff --git a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx deleted file mode 100644 index 6850629ba..000000000 --- a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import AccountContainer from '../../../containers/account_container'; -import { FormattedMessage } from 'react-intl'; - -const outerStyle = { - position: 'relative' -}; - -const headerStyle = { - fontSize: '14px', - fontWeight: '500', - display: 'block', - padding: '10px', - color: '#9baec8', - background: '#454b5e', - overflow: 'hidden' -}; - -const nextStyle = { - display: 'inline-block', - float: 'right', - fontWeight: '400', - color: '#2b90d9' -}; - -const SuggestionsBox = React.createClass({ - - propTypes: { - accountIds: ImmutablePropTypes.list, - perWindow: React.PropTypes.number - }, - - getInitialState () { - return { - index: 0 - }; - }, - - getDefaultProps () { - return { - perWindow: 2 - }; - }, - - mixins: [PureRenderMixin], - - handleNextClick (e) { - e.preventDefault(); - - let newIndex = this.state.index + 1; - - if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) { - newIndex = 0; - } - - this.setState({ index: newIndex }); - }, - - render () { - const { accountIds, perWindow } = this.props; - - if (!accountIds || accountIds.size === 0) { - return <div />; - } - - let nextLink = ''; - - if (accountIds.size > perWindow) { - nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}><FormattedMessage id='suggestions_box.refresh' defaultMessage='Refresh' /></a>; - } - - return ( - <div style={outerStyle}> - <strong style={headerStyle}> - <FormattedMessage id='suggestions_box.who_to_follow' defaultMessage='Who to follow' /> {nextLink} - </strong> - - {accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)} - </div> - ); - } - -}); - -export default SuggestionsBox; diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 87bcd6b99..9897f6505 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -6,7 +6,8 @@ import { cancelReplyCompose, clearComposeSuggestions, fetchComposeSuggestions, - selectComposeSuggestion + selectComposeSuggestion, + changeComposeSensitivity } from '../../../actions/compose'; import { makeGetStatus } from '../../../selectors'; @@ -18,6 +19,7 @@ const makeMapStateToProps = () => { text: state.getIn(['compose', 'text']), suggestion_token: state.getIn(['compose', 'suggestion_token']), suggestions: state.getIn(['compose', 'suggestions']).toJS(), + sensitive: state.getIn(['compose', 'sensitive']), is_submitting: state.getIn(['compose', 'is_submitting']), is_uploading: state.getIn(['compose', 'is_uploading']), in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])) @@ -51,6 +53,10 @@ const mapDispatchToProps = function (dispatch) { onSuggestionSelected (position, accountId) { dispatch(selectComposeSuggestion(position, accountId)); + }, + + onChangeSensitivity (checked) { + dispatch(changeComposeSensitivity(checked)); } } }; diff --git a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx b/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx deleted file mode 100644 index 944ceed85..000000000 --- a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import { connect } from 'react-redux'; -import SuggestionsBox from '../components/suggestions_box'; - -const mapStateToProps = (state) => ({ - accountIds: state.getIn(['user_lists', 'suggestions']) -}); - -export default connect(mapStateToProps)(SuggestionsBox); diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index 5c1b22e00..4017c8949 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -3,9 +3,7 @@ import ComposeFormContainer from './containers/compose_form_container'; import UploadFormContainer from './containers/upload_form_container'; import NavigationContainer from './containers/navigation_container'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import SuggestionsContainer from './containers/suggestions_container'; import SearchContainer from './containers/search_container'; -import { fetchSuggestions } from '../../actions/suggestions'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; @@ -19,7 +17,6 @@ const Compose = React.createClass({ componentDidMount () { this.props.dispatch(mountCompose()); - this.props.dispatch(fetchSuggestions()); }, componentWillUnmount () { @@ -29,14 +26,10 @@ const Compose = React.createClass({ render () { return ( <Drawer> - <div style={{ flex: '1 1 auto' }}> - <SearchContainer /> - <NavigationContainer /> - <ComposeFormContainer /> - <UploadFormContainer /> - </div> - - <SuggestionsContainer /> + <SearchContainer /> + <NavigationContainer /> + <ComposeFormContainer /> + <UploadFormContainer /> </Drawer> ); } diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx index 76ddafb3b..b967d966f 100644 --- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx +++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx @@ -36,9 +36,9 @@ const DetailedStatus = React.createClass({ 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} />; + media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />; } else { - media = <MediaGallery media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; + media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />; } } diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx index 85412635e..4e2a70edb 100644 --- a/app/assets/javascripts/components/locales/de.jsx +++ b/app/assets/javascripts/components/locales/de.jsx @@ -41,8 +41,6 @@ const en = { "search.placeholder": "Suche", "search.account": "Konto", "search.hashtag": "Hashtag", - "suggestions_box.who_to_follow": "Wem folgen", - "suggestions_box.refresh": "Aktualisieren", "upload_button.label": "Media-Datei anfügen", "upload_form.undo": "Entfernen", "notification.follow": "{name} folgt dir", diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index b2c8390c1..41a44e3dc 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -5,9 +5,11 @@ const en = { "status.mention": "Mention", "status.delete": "Delete", "status.reply": "Reply", - "status.reblog": "Reblog", + "status.reblog": "Boost", "status.favourite": "Favourite", - "status.reblogged_by": "{name} reblogged", + "status.reblogged_by": "{name} boosted", + "status.sensitive_warning": "Sensitive content", + "status.sensitive_toggle": "Click to view", "video_player.toggle_sound": "Toggle sound", "account.mention": "Mention", "account.edit_profile": "Edit profile", @@ -34,7 +36,8 @@ const en = { "tabs_bar.public": "Public", "tabs_bar.notifications": "Notifications", "compose_form.placeholder": "What is on your mind?", - "compose_form.publish": "Publish", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Mark content as sensitive", "navigation_bar.settings": "Settings", "navigation_bar.public_timeline": "Public timeline", "navigation_bar.logout": "Logout", @@ -42,13 +45,11 @@ const en = { "search.placeholder": "Search", "search.account": "Account", "search.hashtag": "Hashtag", - "suggestions_box.who_to_follow": "Who to follow", - "suggestions_box.refresh": "Refresh", "upload_button.label": "Add media", "upload_form.undo": "Undo", "notification.follow": "{name} followed you", "notification.favourite": "{name} favourited your status", - "notification.reblog": "{name} reblogged your status", + "notification.reblog": "{name} boosted your status", "notification.mention": "{name} mentioned you" }; diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx index 47377e5ae..d4434bba7 100644 --- a/app/assets/javascripts/components/locales/es.jsx +++ b/app/assets/javascripts/components/locales/es.jsx @@ -42,8 +42,6 @@ const es = { "search.placeholder": "Buscar", "search.account": "Cuenta", "search.hashtag": "Etiqueta", - "suggestions_box.who_to_follow": "A quién seguir", - "suggestions_box.refresh": "Refrescar", "upload_button.label": "Añadir medio", "upload_form.undo": "Deshacer", "notification.follow": "{name} le esta ahora siguiendo", diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx index d6e24c523..c4458a145 100644 --- a/app/assets/javascripts/components/locales/fr.jsx +++ b/app/assets/javascripts/components/locales/fr.jsx @@ -7,22 +7,24 @@ const fr = { "status.reply": "Répondre", "status.reblog": "Partager", "status.favourite": "Ajouter aux favoris", - "status.reblogged_by": "{name} a partagé", + "status.reblogged_by": "{name} a partagé :", + "status.sensitive_warning": "Contenu délicat", + "status.sensitive_toggle": "Cliquer pour dévoiler", "video_player.toggle_sound": "Mettre/Couper le son", "account.mention": "Mentionner", "account.edit_profile": "Modifier le profil", "account.unblock": "Débloquer", - "account.unfollow": "Se désabonner", + "account.unfollow": "Ne plus suivre", "account.block": "Bloquer", - "account.follow": "S’abonner", + "account.follow": "Suivre", "account.posts": "Statuts", "account.follows": "Abonnements", "account.followers": "Abonnés", "account.follows_you": "Vous suit", "getting_started.heading": "Pour commencer", - "getting_started.about_addressing": "Vous pouvez vous abonner aux statuts de quelqu’un en entrant dans le champs de recherche leur nom d’utilisateur et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", - "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, le nom d’utilisateur suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", - "getting_started.about_developer": "Pour s’abonner au développeur de ce projet, c’est Gargron@mastodon.social", + "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", + "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", + "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social", "column.home": "Accueil", "column.mentions": "Mentions", "column.public": "Fil public", @@ -32,23 +34,22 @@ const fr = { "tabs_bar.mentions": "Mentions", "tabs_bar.public": "Public", "tabs_bar.notifications": "Notifications", - "compose_form.placeholder": "Qu’avez vous en tête ?", - "compose_form.publish": "Publier", + "compose_form.placeholder": "Qu’avez-vous en tête ?", + "compose_form.publish": "Pouet", + "compose_form.sensitive": "Marquer le contenu comme délicat", "navigation_bar.settings": "Paramètres", "navigation_bar.public_timeline": "Public", - "navigation_bar.logout": "Se déconnecter", + "navigation_bar.logout": "Déconnexion", "reply_indicator.cancel": "Annuler", "search.placeholder": "Chercher", "search.account": "Compte", "search.hashtag": "Mot-clé", - "suggestions_box.who_to_follow": "Suggestions", - "suggestions_box.refresh": "Rafraîchir", "upload_button.label": "Joindre un média", "upload_form.undo": "Annuler", - "notification.follow": "{name} s’est abonné⋅e à vos statuts", - "notification.favourite": "{name} a ajouté votre statut à ses favoris", - "notification.reblog": "{name} a partagé votre statut", - "notification.mention": "{name} vous a mentionné⋅e" + "notification.follow": "{name} vous suit.", + "notification.favourite": "{name} a ajouté à ses favoris :", + "notification.reblog": "{name} a partagé votre statut :", + "notification.mention": "{name} vous a mentionné⋅e :" }; export default fr; diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx new file mode 100644 index 000000000..4a446965c --- /dev/null +++ b/app/assets/javascripts/components/locales/hu.jsx @@ -0,0 +1,55 @@ +const hu = { + "column_back_button.label": "Vissza", + "lightbox.close": "Bezárás", + "loading_indicator.label": "Betöltés...", + "status.mention": "Említés", + "status.delete": "Törlés", + "status.reply": "Válasz", + "status.reblog": "Reblog", + "status.favourite": "Kedvenc", + "status.reblogged_by": "{name} reblogolta", + "status.sensitive_warning": "Érzékeny tartalom", + "status.sensitive_toggle": "Katt a megtekintéshez", + "video_player.toggle_sound": "Hang kapcsolása", + "account.mention": "Említés", + "account.edit_profile": "Profil szerkesztése", + "account.unblock": "Blokkolás levétele", + "account.unfollow": "Követés abbahagyása", + "account.block": "Blokkolás", + "account.follow": "Követés", + "account.posts": "Posts", + "account.follows": "Követők", + "account.followers": "Követők", + "account.follows_you": "Követnek téged", + "getting_started.heading": "Első lépések", + "getting_started.about_addressing": "Követhetsz embereket felhasználónevük és a doménjük ismeretében, amennyiben megadod ezt az e-mail-szerű címet az oldalsáv tetején lévő rubrikában.", + "getting_started.about_shortcuts": "Ha a célzott személy azonos doménen tartózkodik, a felhasználónév elegendő. Ugyanez érvényes mikor személyeket említesz az állapotokban.", + "getting_started.about_developer": "A projekt fejlesztője követhető, mint Gargron@mastodon.social", + "column.home": "Kezdőlap", + "column.mentions": "Említések", + "column.public": "Nyilvános", + "column.notifications": "Értesítések", + "tabs_bar.compose": "Összeállítás", + "tabs_bar.home": "Kezdőlap", + "tabs_bar.mentions": "Említések", + "tabs_bar.public": "Nyilvános", + "tabs_bar.notifications": "Notifications", + "compose_form.placeholder": "Mire gondolsz?", + "compose_form.publish": "Tülk!", + "compose_form.sensitive": "Tartalom érzékenynek jelölése", + "navigation_bar.settings": "Beállítások", + "navigation_bar.public_timeline": "Nyilvános időfolyam", + "navigation_bar.logout": "Kijelentkezés", + "reply_indicator.cancel": "Mégsem", + "search.placeholder": "Keresés", + "search.account": "Fiók", + "search.hashtag": "Hashtag", + "upload_button.label": "Média hozzáadása", + "upload_form.undo": "Mégsem", + "notification.follow": "{name} követ téged", + "notification.favourite": "{name} kedvencnek jelölte az állapotod", + "notification.reblog": "{name} reblogolta az állapotod", + "notification.mention": "{name} megemlített" +}; + +export default hu; diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx index 7fb43dd33..f172b1c51 100644 --- a/app/assets/javascripts/components/locales/index.jsx +++ b/app/assets/javascripts/components/locales/index.jsx @@ -1,11 +1,17 @@ import en from './en'; import de from './de'; import es from './es'; +import hu from './hu'; +import fr from './fr'; +import pt from './pt'; const locales = { en, de, - es + es, + hu, + fr, + pt }; export default function getMessagesForLocale (locale) { diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx index 02b21f3cb..e67bd80ac 100644 --- a/app/assets/javascripts/components/locales/pt.jsx +++ b/app/assets/javascripts/components/locales/pt.jsx @@ -40,8 +40,6 @@ const pt = { "search.placeholder": "Busca", "search.account": "Conta", "search.hashtag": "Hashtag", - "suggestions_box.who_to_follow": "Quem seguir", - "suggestions_box.refresh": "Recarregar", "upload_button.label": "Adicionar media", "upload_form.undo": "Desfazer" }; diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx index fb161fc4c..3a1473bc1 100644 --- a/app/assets/javascripts/components/middleware/errors.jsx +++ b/app/assets/javascripts/components/middleware/errors.jsx @@ -1,11 +1,13 @@ import { showAlert } from '../actions/alerts'; +const defaultSuccessSuffix = 'SUCCESS'; const defaultFailSuffix = 'FAIL'; export default function errorsMiddleware() { return ({ dispatch }) => next => action => { if (action.type) { const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); + const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g'); if (action.type.match(isFail)) { if (action.error.response) { diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 68247a98c..52be648b3 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -8,7 +8,6 @@ import { ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS } from '../actions/accounts'; -import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions'; import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; import { REBLOG_SUCCESS, @@ -71,7 +70,6 @@ export default function accounts(state = initialState, action) { case ACCOUNT_FETCH_SUCCESS: case NOTIFICATIONS_UPDATE: return normalizeAccount(state, action.account); - case SUGGESTIONS_FETCH_SUCCESS: case FOLLOWERS_FETCH_SUCCESS: case FOLLOWERS_EXPAND_SUCCESS: case FOLLOWING_FETCH_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index e6e86d4f5..4abc3e6aa 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -15,7 +15,8 @@ import { COMPOSE_UPLOAD_PROGRESS, COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, - COMPOSE_SUGGESTION_SELECT + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SENSITIVITY_CHANGE } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { ACCOUNT_SET_SELF } from '../actions/accounts'; @@ -23,6 +24,7 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ mounted: false, + sensitive: false, text: '', in_reply_to: null, is_submitting: false, @@ -87,6 +89,8 @@ export default function compose(state = initialState, action) { return state.set('mounted', true); case COMPOSE_UNMOUNT: return state.set('mounted', false); + case COMPOSE_SENSITIVITY_CHANGE: + return state.set('sensitive', action.checked); case COMPOSE_CHANGE: return state.set('text', action.text); case COMPOSE_REPLY: diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index 0e67e732a..617a833d2 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -3,6 +3,7 @@ import { NOTIFICATIONS_REFRESH_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; +import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; const initialState = Immutable.Map({ @@ -43,6 +44,10 @@ const appendNormalizedNotifications = (state, notifications, next) => { return state.update('items', list => list.push(...items)).set('next', next); }; +const filterNotifications = (state, relationship) => { + return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); +}; + export default function notifications(state = initialState, action) { switch(action.type) { case NOTIFICATIONS_UPDATE: @@ -51,6 +56,8 @@ export default function notifications(state = initialState, action) { return normalizeNotifications(state, action.notifications, action.next); case NOTIFICATIONS_EXPAND_SUCCESS: return appendNormalizedNotifications(state, action.notifications, action.next); + case ACCOUNT_BLOCK_SUCCESS: + return filterNotifications(state, action.relationship); default: return state; } diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index 2a24a75e4..c740b6d64 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -1,7 +1,11 @@ import { + REBLOG_REQUEST, REBLOG_SUCCESS, + REBLOG_FAIL, UNREBLOG_SUCCESS, + FAVOURITE_REQUEST, FAVOURITE_SUCCESS, + FAVOURITE_FAIL, UNFAVOURITE_SUCCESS } from '../actions/interactions'; import { @@ -16,7 +20,8 @@ import { } from '../actions/timelines'; import { ACCOUNT_TIMELINE_FETCH_SUCCESS, - ACCOUNT_TIMELINE_EXPAND_SUCCESS + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import { NOTIFICATIONS_UPDATE, @@ -56,6 +61,18 @@ const deleteStatus = (state, id, references) => { return state.delete(id); }; +const filterStatuses = (state, relationship) => { + state.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id'))); + }); + + return state; +}; + const initialState = Immutable.Map(); export default function statuses(state = initialState, action) { @@ -69,6 +86,14 @@ export default function statuses(state = initialState, action) { case FAVOURITE_SUCCESS: case UNFAVOURITE_SUCCESS: return normalizeStatus(state, action.response); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.setIn([action.status.get('id'), 'favourited'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.setIn([action.status.get('id'), 'reblogged'], false); case TIMELINE_REFRESH_SUCCESS: case TIMELINE_EXPAND_SUCCESS: case ACCOUNT_TIMELINE_FETCH_SUCCESS: @@ -79,6 +104,8 @@ export default function statuses(state = initialState, action) { return normalizeStatuses(state, action.statuses); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + return filterStatuses(state, action.relationship); default: return state; } diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 9e79a4100..358734eaf 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -13,7 +13,8 @@ import { import { ACCOUNT_FETCH_SUCCESS, ACCOUNT_TIMELINE_FETCH_SUCCESS, - ACCOUNT_TIMELINE_EXPAND_SUCCESS + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import { STATUS_FETCH_SUCCESS, @@ -140,6 +141,21 @@ const deleteStatus = (state, id, accountId, references) => { return state; }; +const filterTimelines = (state, relationship, statuses) => { + let references; + + statuses.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]); + state = deleteStatus(state, status.get('id'), status.get('account'), references); + }); + + return state; +}; + const normalizeContext = (state, id, ancestors, descendants) => { const ancestorsIds = ancestors.map(ancestor => ancestor.get('id')); const descendantsIds = descendants.map(descendant => descendant.get('id')); @@ -166,6 +182,8 @@ export default function timelines(state = initialState, action) { return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); case ACCOUNT_TIMELINE_EXPAND_SUCCESS: return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); + case ACCOUNT_BLOCK_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); default: return state; } diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx index 65598f8a0..3608e4209 100644 --- a/app/assets/javascripts/components/reducers/user_lists.jsx +++ b/app/assets/javascripts/components/reducers/user_lists.jsx @@ -4,7 +4,6 @@ import { FOLLOWING_FETCH_SUCCESS, FOLLOWING_EXPAND_SUCCESS } from '../actions/accounts'; -import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions'; import { REBLOGS_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS @@ -14,7 +13,6 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ followers: Immutable.Map(), following: Immutable.Map(), - suggestions: Immutable.List(), reblogged_by: Immutable.Map(), favourited_by: Immutable.Map() }); @@ -42,8 +40,6 @@ export default function userLists(state = initialState, action) { return normalizeList(state, 'following', action.id, action.accounts, action.next); case FOLLOWING_EXPAND_SUCCESS: return appendToList(state, 'following', action.id, action.accounts, action.next); - case SUGGESTIONS_FETCH_SUCCESS: - return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))); case REBLOGS_FETCH_SUCCESS: return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); case FAVOURITES_FETCH_SUCCESS: diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 05a309365..bbbeafefe 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -234,3 +234,4 @@ body { @import 'stream_entries'; @import 'components'; @import 'about'; +@import 'tables'; diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index adf0db990..cc9f0eb3b 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -405,3 +405,109 @@ text-decoration: underline; } } + +.react-toggle { + display: inline-block; + position: relative; + cursor: pointer; + background-color: transparent; + border: 0; + padding: 0; + user-select: none; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: transparent; +} + +.react-toggle-screenreader-only { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.react-toggle--disabled { + cursor: not-allowed; + opacity: 0.5; + transition: opacity 0.25s; +} + +.react-toggle-track { + width: 50px; + height: 24px; + padding: 0; + border-radius: 30px; + background-color: #282c37; + transition: all 0.2s ease; +} + +.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: darken(#282c37, 10%); +} + +.react-toggle--checked .react-toggle-track { + background-color: #2b90d9; +} + +.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { + background-color: lighten(#2b90d9, 10%); +} + +.react-toggle-track-check { + position: absolute; + width: 14px; + height: 10px; + top: 0px; + bottom: 0px; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + left: 8px; + opacity: 0; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-check { + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle-track-x { + position: absolute; + width: 10px; + height: 10px; + top: 0px; + bottom: 0px; + margin-top: auto; + margin-bottom: auto; + line-height: 0; + right: 10px; + opacity: 1; + transition: opacity 0.25s ease; +} + +.react-toggle--checked .react-toggle-track-x { + opacity: 0; +} + +.react-toggle-thumb { + transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; + position: absolute; + top: 1px; + left: 1px; + width: 22px; + height: 22px; + border: 1px solid #282c37; + border-radius: 50%; + background-color: #FAFAFA; + box-sizing: border-box; + transition: all 0.25s ease; +} + +.react-toggle--checked .react-toggle-thumb { + left: 27px; + border-color: #2b90d9; +} diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index c7bdbe2c0..81270edf6 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -48,11 +48,16 @@ code { display: block; } - input[type=checkbox] { - display: inline-block; + label.checkbox { position: relative; - top: 3px; - margin-right: 8px; + padding-left: 25px; + } + + input[type=checkbox] { + position: absolute; + left: 0; + top: 1px; + margin: 0; } } diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss new file mode 100644 index 000000000..89b35891d --- /dev/null +++ b/app/assets/stylesheets/tables.scss @@ -0,0 +1,25 @@ +.table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + + th, td { + padding: 8px; + line-height: 1.42857143; + vertical-align: top; + border-top: 1px solid #ddd; + text-align: left; + } + + & > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #ddd; + border-top: 0; + font-weight: 500; + } +} + +samp { + font-family: 'Roboto Mono', monospace; +} diff --git a/app/controllers/admin/pubsubhubbub_controller.rb b/app/controllers/admin/pubsubhubbub_controller.rb new file mode 100644 index 000000000..7e6bc75ea --- /dev/null +++ b/app/controllers/admin/pubsubhubbub_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Admin::PubsubhubbubController < ApplicationController + before_action :require_admin! + + layout 'public' + + def index + @subscriptions = Subscription.order('id desc').includes(:account).paginate(page: params[:page], per_page: 40) + end +end diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb new file mode 100644 index 000000000..78d4e36e6 --- /dev/null +++ b/app/controllers/api/push_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Api::PushController < ApiController + def update + mode = params['hub.mode'] + topic = params['hub.topic'] + callback = params['hub.callback'] + lease_seconds = params['hub.lease_seconds'] + secret = params['hub.secret'] + + case mode + when 'subscribe' + response, status = Pubsubhubbub::SubscribeService.new.call(topic_to_account(topic), callback, secret, lease_seconds) + when 'unsubscribe' + response, status = Pubsubhubbub::UnsubscribeService.new.call(topic_to_account(topic), callback) + else + response = "Unknown mode: #{mode}" + status = 422 + end + + render plain: response, status: status + end + + private + + def topic_to_account(topic_url) + return if topic_url.blank? + + uri = Addressable::URI.parse(topic_url) + params = Rails.application.routes.recognize_path(uri.path) + domain = uri.host + (uri.port ? ":#{uri.port}" : '') + + return unless TagManager.instance.local_domain?(domain) && params[:controller] == 'accounts' && params[:action] == 'show' && params[:format] == 'atom' + + Account.find_local(params[:username]) + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 2dfab0831..9a356196c 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -18,9 +18,11 @@ class Api::V1::AccountsController < ApiController def following results = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) - accounts = Account.where(id: results.map(&:target_account_id)).with_counters.map { |a| [a.id, a] }.to_h + accounts = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.target_account_id] } + set_account_counters_maps(@accounts) + next_path = following_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT prev_path = following_api_v1_account_url(since_id: results.first.id) unless results.empty? @@ -31,9 +33,11 @@ class Api::V1::AccountsController < ApiController def followers results = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) - accounts = Account.where(id: results.map(&:account_id)).with_counters.map { |a| [a.id, a] }.to_h + accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.account_id] } + set_account_counters_maps(@accounts) + next_path = followers_api_v1_account_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT prev_path = followers_api_v1_account_url(since_id: results.first.id) unless results.empty? @@ -42,20 +46,12 @@ class Api::V1::AccountsController < ApiController render action: :index end - def common_followers - @accounts = @account.common_followers_with(current_user.account) - render action: :index - end - - def suggestions - @accounts = FollowSuggestion.get(current_user.account_id) - render action: :index - end - def statuses - @statuses = @account.statuses.with_includes.with_counters.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = @account.statuses.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = cache_collection(@statuses, Status) set_maps(@statuses) + set_counters_maps(@statuses) next_path = statuses_api_v1_account_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty? @@ -98,6 +94,9 @@ class Api::V1::AccountsController < ApiController def search limit = params[:limit] ? [DEFAULT_ACCOUNTS_LIMIT, params[:limit].to_i].min : DEFAULT_ACCOUNTS_LIMIT @accounts = SearchService.new.call(params[:q], limit, params[:resolve] == 'true') + + set_account_counters_maps(@accounts) + render action: :index end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index bb8e8d9ee..f8139ade7 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -4,6 +4,9 @@ class Api::V1::MediaController < ApiController before_action -> { doorkeeper_authorize! :write } before_action :require_user! + include ObfuscateFilename + obfuscate_filename :file + respond_to :json def create diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index c76189e87..a24e0beb7 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -7,7 +7,8 @@ class Api::V1::NotificationsController < ApiController respond_to :json def index - @notifications = Notification.where(account: current_account).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @notifications = Notification.where(account: current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) + @notifications = cache_collection(@notifications, Notification) statuses = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status) set_maps(statuses) diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 604e2969d..a0b15cfbc 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -9,18 +9,25 @@ class Api::V1::StatusesController < ApiController respond_to :json def show + cached = Rails.cache.read(@status.cache_key) + @status = cached unless cached.nil? end def context @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account)) - set_maps([@status] + @context[:ancestors] + @context[:descendants]) + statuses = [@status] + @context[:ancestors] + @context[:descendants] + + set_maps(statuses) + set_counters_maps(statuses) end def reblogged_by results = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) - accounts = Account.where(id: results.map(&:account_id)).with_counters.map { |a| [a.id, a] }.to_h + accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |r| accounts[r.account_id] } + set_account_counters_maps(@accounts) + next_path = reblogged_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) unless results.empty? @@ -31,9 +38,11 @@ class Api::V1::StatusesController < ApiController def favourited_by results = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id]) - accounts = Account.where(id: results.map(&:account_id)).with_counters.map { |a| [a.id, a] }.to_h + accounts = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h @accounts = results.map { |f| accounts[f.account_id] } + set_account_counters_maps(@accounts) + next_path = favourited_by_api_v1_status_url(max_id: results.last.id) if results.size == DEFAULT_ACCOUNTS_LIMIT prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) unless results.empty? @@ -43,13 +52,13 @@ class Api::V1::StatusesController < ApiController 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]) + @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive]) render action: :show end def destroy @status = Status.where(account_id: current_user.account).find(params[:id]) - RemoveStatusService.new.call(@status) + RemovalWorker.perform_async(@status.id) render_empty end @@ -59,8 +68,12 @@ class Api::V1::StatusesController < ApiController end def unreblog - RemoveStatusService.new.call(Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!) - @status = Status.find(params[:id]) + reblog = Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first! + @status = reblog.reblog + @reblogged_map = { @status.id => false } + + RemovalWorker.perform_async(reblog.id) + render action: :show end diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb index 19b76f11d..89e54e2cf 100644 --- a/app/controllers/api/v1/timelines_controller.rb +++ b/app/controllers/api/v1/timelines_controller.rb @@ -8,8 +8,11 @@ class Api::V1::TimelinesController < ApiController def home @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = cache_collection(@statuses) set_maps(@statuses) + set_counters_maps(@statuses) + set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) next_path = api_v1_home_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? @@ -21,8 +24,11 @@ class Api::V1::TimelinesController < ApiController def mentions @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = cache_collection(@statuses) set_maps(@statuses) + set_counters_maps(@statuses) + set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT prev_path = api_v1_mentions_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? @@ -34,8 +40,11 @@ class Api::V1::TimelinesController < ApiController def public @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = cache_collection(@statuses) set_maps(@statuses) + set_counters_maps(@statuses) + set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) next_path = api_v1_public_timeline_url(max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty? @@ -48,8 +57,11 @@ class Api::V1::TimelinesController < ApiController def tag @tag = Tag.find_by(name: params[:id].downcase) @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = cache_collection(@statuses) set_maps(@statuses) + set_counters_maps(@statuses) + set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq) next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id) if @statuses.size == DEFAULT_STATUSES_LIMIT prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty? @@ -58,4 +70,10 @@ class Api::V1::TimelinesController < ApiController render action: :index end + + private + + def cache_collection(raw) + super(raw, Status) + end end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 862358d6e..d2d3bc4a4 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -48,7 +48,7 @@ class ApiController < ApplicationController response.headers['X-RateLimit-Limit'] = match_data[:limit].to_s response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s - response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s + response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6) end def set_pagination_headers(next_path = nil, prev_path = nil) @@ -59,7 +59,7 @@ class ApiController < ApplicationController end def current_resource_owner - User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token + @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end def current_user diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3a4c95db4..ba0098c71 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -31,6 +31,10 @@ class ApplicationController < ActionController::Base I18n.locale = I18n.default_locale end + def require_admin! + redirect_to root_path unless current_user&.admin? + end + protected def not_found @@ -46,6 +50,25 @@ class ApplicationController < ActionController::Base end def current_account - current_user.try(:account) + @current_account ||= current_user.try(:account) + end + + def cache_collection(raw, klass) + uncached_ids = [] + cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) + + raw.each do |item| + uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key) + end + + unless uncached_ids.empty? + uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h + + uncached.values.each do |item| + Rails.cache.write(item.cache_key, item) + end + end + + raw.map { |item| cached_keys_with_value[item.cache_key] || uncached[item.id] }.compact end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 5be8719ae..cacc03b65 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -14,7 +14,10 @@ class Settings::PreferencesController < ApplicationController current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1' current_user.settings(:notification_emails).mention = user_params[:notification_emails][:mention] == '1' - if current_user.update(user_params.except(:notification_emails)) + current_user.settings(:interactions).must_be_follower = user_params[:interactions][:must_be_follower] == '1' + current_user.settings(:interactions).must_be_following = user_params[:interactions][:must_be_following] == '1' + + if current_user.update(user_params.except(:notification_emails, :interactions)) redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg') else render action: :show @@ -24,6 +27,6 @@ class Settings::PreferencesController < ApplicationController private def user_params - params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention]) + params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following]) end end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 4b2b5a131..21fbba2af 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -6,6 +6,10 @@ class Settings::ProfilesController < ApplicationController before_action :authenticate_user! before_action :set_account + include ObfuscateFilename + obfuscate_filename [:account, :avatar] + obfuscate_filename [:account, :header] + def show end diff --git a/app/helpers/admin/pubsubhubbub_helper.rb b/app/helpers/admin/pubsubhubbub_helper.rb new file mode 100644 index 000000000..41c874a62 --- /dev/null +++ b/app/helpers/admin/pubsubhubbub_helper.rb @@ -0,0 +1,2 @@ +module Admin::PubsubhubbubHelper +end diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb index 52190adae..13faaa261 100644 --- a/app/helpers/atom_builder_helper.rb +++ b/app/helpers/atom_builder_helper.rb @@ -116,9 +116,9 @@ module AtomBuilderHelper end def link_avatar(xml, account) - single_link_avatar(xml, account, :large, 300) - single_link_avatar(xml, account, :medium, 96) - single_link_avatar(xml, account, :small, 48) + single_link_avatar(xml, account, :large, 300) + # single_link_avatar(xml, account, :medium, 96) + # single_link_avatar(xml, account, :small, 48) end def logo(xml, url) diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 75ee2f8d9..26c4cd58f 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -5,7 +5,9 @@ module SettingsHelper en: 'English', de: 'Deutsch', es: 'Español', + pt: 'Português', fr: 'Français', + hu: 'Magyar', }.freeze def human_locale(locale) diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index c8512476d..b812ad1f4 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -68,30 +68,34 @@ class FeedManager def filter_from_home?(status, receiver) should_filter = false - if status.reply? && !status.thread.account.nil? # Filter out if it's a reply - should_filter = !receiver.following?(status.thread.account) # and I'm not following the person it's a reply to - should_filter &&= !(receiver.id == status.thread.account_id) # and it's not a reply to me - should_filter &&= !(status.account_id == status.thread.account_id) # and it's not a self-reply - elsif status.reblog? # Filter out a reblog - should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person + if status.reply? && !status.thread.account.nil? # Filter out if it's a reply + should_filter = !receiver.following?(status.thread.account) # and I'm not following the person it's a reply to + should_filter &&= !(receiver.id == status.thread.account_id) # and it's not a reply to me + should_filter &&= !(status.account_id == status.thread.account_id) # and it's not a self-reply + elsif status.reblog? # Filter out a reblog + should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person end + should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked + should_filter end def filter_from_mentions?(status, receiver) - should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself - should_filter ||= receiver.blocking?(status.account) # or it's from someone I blocked + should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself + should_filter ||= receiver.blocking?(status.account) # or it's from someone I blocked + should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked - if status.reply? && !status.thread.account.nil? # or it's a reply - should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked + if status.reply? && !status.thread.account.nil? # or it's a reply + should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked end should_filter end def filter_from_public?(status, receiver) - should_filter = receiver.blocking?(status.account) + should_filter = receiver.blocking?(status.account) + should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) if status.reply? && !status.thread.account.nil? should_filter ||= receiver.blocking?(status.thread.account) diff --git a/app/models/account.rb b/app/models/account.rb index 16d654195..0f3d0dda2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -13,12 +13,12 @@ class Account < ApplicationRecord validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?' # Avatar upload - has_attached_file :avatar, styles: { large: '300x300#', medium: '96x96#', small: '48x48#' } + has_attached_file :avatar, styles: { large: '300x300#' }, convert_options: { all: '-strip' } validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES validates_attachment_size :avatar, less_than: 2.megabytes # Header upload - has_attached_file :header, styles: { medium: '700x335#' } + has_attached_file :header, styles: { medium: '700x335#' }, convert_options: { all: '-strip' } validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES validates_attachment_size :header, less_than: 2.megabytes @@ -44,8 +44,12 @@ class Account < ApplicationRecord has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account + # Media has_many :media_attachments, dependent: :destroy + # PuSH subscriptions + has_many :subscriptions, dependent: :destroy + pg_search_scope :search_for, against: { username: 'A', domain: 'B' }, using: { tsearch: { prefix: true } } scope :remote, -> { where.not(domain: nil) } @@ -66,12 +70,12 @@ class Account < ApplicationRecord def unfollow!(other_account) follow = active_relationships.find_by(target_account: other_account) - follow.destroy unless follow.nil? + follow&.destroy end def unblock!(other_account) block = block_relationships.find_by(target_account: other_account) - block.destroy unless block.nil? + block&.destroy end def following?(other_account) @@ -116,7 +120,11 @@ class Account < ApplicationRecord end def avatar_remote_url=(url) - self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url + parsed_url = URI.parse(url) + + return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url + + self.avatar = parsed_url self[:avatar_remote_url] = url rescue OpenURI::HTTPError => e Rails.logger.debug "Error fetching remote avatar: #{e}" @@ -130,15 +138,6 @@ class Account < ApplicationRecord username end - def common_followers_with(other_account) - results = Neography::Rest.new.execute_query('MATCH (a {account_id: {a_id}})-[:follows]->(b)-[:follows]->(c {account_id: {c_id}}) RETURN b.account_id', a_id: id, c_id: other_account.id) - ids = results['data'].map(&:first) - accounts = Account.where(id: ids).with_counters.limit(20).map { |a| [a.id, a] }.to_h - ids.map { |id| accounts[id] }.compact - rescue Neography::NeographyError, Excon::Error::Socket - [] - end - class << self def find_local!(username) find_remote!(username, nil) diff --git a/app/models/concerns/obfuscate_filename.rb b/app/models/concerns/obfuscate_filename.rb new file mode 100644 index 000000000..dc25cdbc2 --- /dev/null +++ b/app/models/concerns/obfuscate_filename.rb @@ -0,0 +1,16 @@ +module ObfuscateFilename + extend ActiveSupport::Concern + + class_methods do + def obfuscate_filename(*args) + before_action { obfuscate_filename(*args) } + end + end + + def obfuscate_filename(path) + file = params.dig(*path) + return if file.nil? + + file.original_filename = "media" + File.extname(file.original_filename) + end +end diff --git a/app/models/feed.rb b/app/models/feed.rb index e7f2ab3a5..7b181d529 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -16,8 +16,8 @@ class Feed RegenerationWorker.perform_async(@account.id, @type) @statuses = Status.send("as_#{@type}_timeline", @account).paginate_by_max_id(limit, nil, nil) else - status_map = Status.where(id: unhydrated).with_includes.with_counters.map { |status| [status.id, status] }.to_h - @statuses = unhydrated.map { |id| status_map[id] }.compact + status_map = Status.where(id: unhydrated).map { |s| [s.id, s] }.to_h + @statuses = unhydrated.map { |id| status_map[id] }.compact end @statuses diff --git a/app/models/follow.rb b/app/models/follow.rb index cc5bceb75..f83490caa 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -27,32 +27,4 @@ class Follow < ApplicationRecord def title destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}" end - - after_create :add_to_graph - after_destroy :remove_from_graph - - def sync! - add_to_graph - end - - private - - def add_to_graph - neo = Neography::Rest.new - - a = neo.create_unique_node('account_index', 'Account', account_id.to_s, account_id: account_id) - b = neo.create_unique_node('account_index', 'Account', target_account_id.to_s, account_id: target_account_id) - - neo.create_unique_relationship('follow_index', 'Follow', id.to_s, 'follows', a, b) - rescue Neography::NeographyError, Excon::Error::Socket => e - Rails.logger.error e - end - - def remove_from_graph - neo = Neography::Rest.new - rel = neo.get_relationship_index('follow_index', 'Follow', id.to_s) - neo.delete_relationship(rel) - rescue Neography::NeographyError, Excon::Error::Socket => e - Rails.logger.error e - end end diff --git a/app/models/follow_suggestion.rb b/app/models/follow_suggestion.rb deleted file mode 100644 index 2daa40dcb..000000000 --- a/app/models/follow_suggestion.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -class FollowSuggestion - class << self - def get(for_account_id, limit = 10) - neo = Neography::Rest.new - - query = <<END -MATCH (a {account_id: {id}})-[:follows]->(b)-[:follows]->(c) -WHERE a <> c -AND NOT (a)-[:follows]->(c) -RETURN DISTINCT c.account_id, count(b), c.nodeRank -ORDER BY count(b) DESC, c.nodeRank DESC -LIMIT {limit} -END - - results = neo.execute_query(query, id: for_account_id, limit: limit) - - if results.empty? || results['data'].empty? - results = fallback(for_account_id, limit) - elsif results['data'].size < limit - results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq - end - - account_ids = results['data'].map(&:first) - blocked_ids = Block.where(account_id: for_account_id).pluck(:target_account_id) - accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h - - account_ids.map { |id| accounts_map[id] }.compact - rescue Neography::NeographyError, Excon::Error::Socket => e - Rails.logger.error e - return [] - end - - private - - def fallback(for_account_id, limit) - neo = Neography::Rest.new - - query = <<END -MATCH (b) -RETURN b.account_id -ORDER BY b.nodeRank DESC -LIMIT {limit} -END - - neo.execute_query(query, id: for_account_id, limit: limit) - end - end -end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index bfbf00d76..f1b9b8112 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -16,6 +16,8 @@ class MediaAttachment < ApplicationRecord validates :account, presence: true + default_scope { order('id asc') } + def local? remote_url.blank? end diff --git a/app/models/status.rb b/app/models/status.rb index 1bf4b49c9..f9dcd97e4 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -4,7 +4,7 @@ class Status < ApplicationRecord include Paginable include Streamable - belongs_to :account, -> { with_counters }, inverse_of: :statuses + belongs_to :account, inverse_of: :statuses belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, touch: true @@ -27,7 +27,7 @@ class Status < ApplicationRecord default_scope { order('id desc') } 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, :tags, :stream_entry, mentions: :account, reblog: [:account, mentions: :account], thread: :account) } + scope :with_includes, -> { includes(:account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account) } def local? uri.nil? @@ -71,7 +71,7 @@ class Status < ApplicationRecord def ancestors(account = nil) ids = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, in_reply_to_id, path) AS (SELECT id, in_reply_to_id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id FROM search_tree JOIN statuses ON statuses.id = search_tree.in_reply_to_id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path DESC', id]) - [self]).pluck(:id) - statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id) + statuses = Status.where(id: ids).with_includes.group_by(&:id) results = ids.map { |id| statuses[id].first } results = results.reject { |status| account.blocking?(status.account) } unless account.nil? @@ -80,7 +80,7 @@ class Status < ApplicationRecord def descendants(account = nil) ids = (Status.find_by_sql(['WITH RECURSIVE search_tree(id, path) AS (SELECT id, ARRAY[id] FROM statuses WHERE id = ? UNION ALL SELECT statuses.id, path || statuses.id FROM search_tree JOIN statuses ON statuses.in_reply_to_id = search_tree.id WHERE NOT statuses.id = ANY(path)) SELECT id FROM search_tree ORDER BY path', id]) - [self]).pluck(:id) - statuses = Status.where(id: ids).with_counters.with_includes.group_by(&:id) + statuses = Status.where(id: ids).with_includes.group_by(&:id) results = ids.map { |id| statuses[id].first } results = results.reject { |status| account.blocking?(status.account) } unless account.nil? @@ -89,28 +89,30 @@ class Status < ApplicationRecord class << self def as_home_timeline(account) - where(account: [account] + account.following).with_includes.with_counters + where(account: [account] + account.following).with_includes end def as_mentions_timeline(account) - where(id: Mention.where(account: account).pluck(:status_id)).with_includes.with_counters + where(id: Mention.where(account: account).pluck(:status_id)).with_includes end def as_public_timeline(account = nil) - query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id').where('accounts.silenced = FALSE') + query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') + .where('accounts.silenced = FALSE') + .where('statuses.in_reply_to_id IS NULL') + .where('statuses.reblog_of_id IS NULL') query = filter_timeline(query, account) unless account.nil? - - query.with_includes.with_counters + query end def as_tag_timeline(tag, account = nil) query = tag.statuses .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') .where('accounts.silenced = FALSE') - + .where('statuses.in_reply_to_id IS NULL') + .where('statuses.reblog_of_id IS NULL') query = filter_timeline(query, account) unless account.nil? - - query.with_includes.with_counters + query end def favourites_map(status_ids, account_id) @@ -126,13 +128,7 @@ class Status < ApplicationRecord def filter_timeline(query, account) blocked = Block.where(account: account).pluck(:target_account_id) return query if blocked.empty? - - query - .joins('LEFT OUTER JOIN statuses AS parents ON statuses.in_reply_to_id = parents.id') - .joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id') - .where('statuses.account_id NOT IN (?)', blocked) - .where('(parents.id IS NULL OR parents.account_id NOT IN (?))', blocked) - .where('(reblogs.id IS NULL OR reblogs.account_id NOT IN (?))', blocked) + query.where('statuses.account_id NOT IN (?)', blocked) end end diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 000000000..497cabb09 --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Subscription < ApplicationRecord + MIN_EXPIRATION = 3600 * 24 * 7 + MAX_EXPIRATION = 3600 * 24 * 30 + + belongs_to :account + + validates :callback_url, presence: true + validates :callback_url, uniqueness: { scope: :account_id } + + scope :active, -> { where(confirmed: true).where('expires_at > ?', Time.now.utc) } + + def lease_seconds=(str) + self.expires_at = Time.now.utc + [[MIN_EXPIRATION, str.to_i].max, MAX_EXPIRATION].min.seconds + end + + def lease_seconds + (expires_at - Time.now.utc).to_i + end + + before_validation :set_min_expiration + + private + + def set_min_expiration + self.lease_seconds = 0 unless expires_at + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 7e3547dff..423833d47 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,7 +14,8 @@ class User < ApplicationRecord scope :admins, -> { where(admin: true) } has_settings do |s| - s.key :notification_emails, defaults: { follow: true, reblog: true, favourite: true, mention: true } + s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false } + s.key :interactions, defaults: { must_be_follower: false, must_be_following: false } end def send_devise_notification(notification, *args) diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 388a592e0..6a032a5a1 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -6,19 +6,27 @@ class BlockService < BaseService UnfollowService.new.call(account, target_account) if account.following?(target_account) account.block!(target_account) - clear_mentions(account, target_account) + clear_timelines(account, target_account) + clear_notifications(account, target_account) end private - def clear_mentions(account, target_account) - timeline_key = FeedManager.instance.key(:mentions, account.id) + def clear_timelines(account, target_account) + mentions_key = FeedManager.instance.key(:mentions, account.id) + home_key = FeedManager.instance.key(:home, account.id) target_account.statuses.select('id').find_each do |status| - redis.zrem(timeline_key, status.id) + redis.zrem(mentions_key, status.id) + redis.zrem(home_key, status.id) end + end - FeedManager.instance.broadcast(account.id, type: 'block', id: target_account.id) + def clear_notifications(account, target_account) + Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all + Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all end def redis diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 78cb0b13f..40d8a0fee 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -24,7 +24,7 @@ class FanOutOnWriteService < BaseService def deliver_to_followers(status) Rails.logger.debug "Delivering status #{status.id} to followers" - status.account.followers.where(domain: nil).find_each do |follower| + status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).find_each do |follower| next if FeedManager.instance.filter?(:home, status, follower) FeedManager.instance.push(:home, follower, status) end @@ -41,14 +41,17 @@ class FanOutOnWriteService < BaseService end def deliver_to_hashtags(status) - Rails.logger.debug "Delivering status #{status.id} to hashtags" + return if status.reblog? || status.reply? + Rails.logger.debug "Delivering status #{status.id} to hashtags" status.tags.find_each do |tag| FeedManager.instance.broadcast("hashtag:#{tag.name}", type: 'update', id: status.id) end end def deliver_to_public(status) + return if status.reblog? || status.reply? + Rails.logger.debug "Delivering status #{status.id} to public timeline" FeedManager.instance.broadcast(:public, type: 'update', id: status.id) end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 781b03b40..2f280e03f 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -7,7 +7,9 @@ class FavouriteService < BaseService # @return [Favourite] def call(account, status) favourite = Favourite.create!(account: account, status: status) + HubPingWorker.perform_async(account.id) + Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id) if status.local? NotifyService.new.call(status.account, favourite) diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb index 37339d8ed..f640222b0 100644 --- a/app/services/follow_remote_account_service.rb +++ b/app/services/follow_remote_account_service.rb @@ -80,8 +80,7 @@ class FollowRemoteAccountService < BaseService end def get_profile(xml, account) - author = xml.at_xpath('/xmlns:feed/xmlns:author') || xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS) - update_remote_profile_service.call(author, account) + update_remote_profile_service.call(xml.at_xpath('/xmlns:feed'), account) end def update_remote_profile_service diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index cdae254bf..09fa295e3 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -19,7 +19,10 @@ class FollowService < BaseService end merge_into_timeline(target_account, source_account) + HubPingWorker.perform_async(source_account.id) + Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) + follow end @@ -33,7 +36,6 @@ class FollowService < BaseService end FeedManager.instance.trim(:home, into_account.id) - FeedManager.instance.broadcast(into_account.id, type: 'merge') end def redis diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 772adfb90..1efd326b0 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -36,6 +36,8 @@ class NotifyService < BaseService blocked = false blocked ||= @recipient.id == @notification.from_account.id blocked ||= @recipient.blocking?(@notification.from_account) + blocked ||= (@recipient.user.settings(:interactions).must_be_follower && !@notification.from_account.following?(@recipient)) + blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account)) blocked ||= send("blocked_#{@notification.type}?") blocked end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index cf824ff99..979a157e9 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -5,15 +5,20 @@ class PostStatusService < BaseService # @param [Account] account Account from which to post # @param [String] text Message # @param [Status] in_reply_to Optional status to reply to - # @param [Enumerable] media_ids Optional array of media IDs to attach + # @param [Hash] options + # @option [Boolean] :sensitive + # @option [Enumerable] :media_ids Optional array of media IDs to attach # @return [Status] - 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) + def call(account, text, in_reply_to = nil, options = {}) + status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive]) + attach_media(status, options[:media_ids]) process_mentions_service.call(status) process_hashtags_service.call(status) + DistributionWorker.perform_async(status.id) HubPingWorker.perform_async(account.id) + Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + status end diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 1cd801b80..a7a4cb2b0 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -16,7 +16,7 @@ class ProcessFeedService < BaseService def update_author(xml, account) return if xml.at_xpath('/xmlns:feed').nil? - UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed/xmlns:author'), account) + UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed'), account, true) end def process_entries(xml, account) diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index 3bf3471ec..fa14c44da 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService def call(status, tags = []) tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local? - tags.map(&:downcase).uniq.each do |tag| + tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag| status.tags << Tag.where(name: tag).first_or_initialize(name: tag) end end diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index e7bb3c73b..6b2f6e2d2 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -26,7 +26,7 @@ class ProcessInteractionService < BaseService end if salmon.verify(envelope, account.keypair) - update_remote_profile_service.call(xml.at_xpath('/xmlns:entry/xmlns:author'), account) + update_remote_profile_service.call(xml.at_xpath('/xmlns:entry'), account, true) case verb(xml) when :follow @@ -74,7 +74,7 @@ class ProcessInteractionService < BaseService end def delete_post!(xml, account) - status = Status.find(activity_id(xml)) + status = Status.find(xml.at_xpath('//xmlns:id').content) return if status.nil? diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb new file mode 100644 index 000000000..343376d77 --- /dev/null +++ b/app/services/pubsubhubbub/subscribe_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Pubsubhubbub::SubscribeService < BaseService + def call(account, callback, secret, lease_seconds) + return ['Invalid topic URL', 422] if account.nil? + return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/ + + subscription = Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback) + Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds) + + ['', 202] + end +end diff --git a/app/services/pubsubhubbub/unsubscribe_service.rb b/app/services/pubsubhubbub/unsubscribe_service.rb new file mode 100644 index 000000000..62459a0aa --- /dev/null +++ b/app/services/pubsubhubbub/unsubscribe_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Pubsubhubbub::UnsubscribeService < BaseService + def call(account, callback) + return ['Invalid topic URL', 422] if account.nil? + + subscription = Subscription.where(account: account, callback_url: callback) + + unless subscription.nil? + Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe') + end + + ['', 202] + end +end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 6543d4ae7..39fdb4ea7 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -7,8 +7,10 @@ class ReblogService < BaseService # @return [Status] def call(account, reblogged_status) reblog = account.statuses.create!(reblog: reblogged_status, text: '') + DistributionWorker.perform_async(reblog.id) HubPingWorker.perform_async(account.id) + Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) if reblogged_status.local? NotifyService.new.call(reblogged_status.account, reblog) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 689abc97b..4e03661da 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -10,6 +10,11 @@ class RemoveStatusService < BaseService remove_from_public(status) status.destroy! + + if status.account.local? + HubPingWorker.perform_async(status.account.id) + Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + end end private diff --git a/app/services/search_service.rb b/app/services/search_service.rb index c4cffda13..1ae1d5a80 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -2,9 +2,9 @@ class SearchService < BaseService def call(query, limit, resolve = false) - return if query.blank? + return if query.blank? || query.start_with?('#') - username, domain = query.split('@') + username, domain = query.gsub(/\A@/, '').split('@') results = if domain.nil? Account.search_for(username) @@ -12,7 +12,7 @@ class SearchService < BaseService Account.search_for("#{username} #{domain}") end - results = results.limit(limit).with_counters + results = results.limit(limit) if resolve && results.empty? && !domain.nil? results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")] diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index b3386a99c..7973a3611 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -17,9 +17,8 @@ class UnfollowService < BaseService from_account.statuses.select('id').find_each do |status| redis.zrem(timeline_key, status.id) + redis.zremrangebyscore(timeline_key, status.id, status.id) end - - FeedManager.instance.broadcast(into_account.id, type: 'unmerge') end def redis diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb index 2909ae12a..56b25816f 100644 --- a/app/services/update_remote_profile_service.rb +++ b/app/services/update_remote_profile_service.rb @@ -2,24 +2,24 @@ class UpdateRemoteProfileService < BaseService POCO_NS = 'http://portablecontacts.net/spec/1.0' + DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' - def call(author_xml, account) - return if author_xml.nil? + def call(xml, account, resubscribe = false) + return if xml.nil? - account.display_name = if author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil? - account.username - else - author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content - end + author_xml = xml.at_xpath('./xmlns:author') || xml.at_xpath('./dfrn:owner', dfrn: DFRN_NS) + hub_link = xml.at_xpath('./xmlns:link[@rel="hub"]') - unless author_xml.at_xpath('./poco:note').nil? - account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content - end - - unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil? - account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]').attribute('href').value + unless author_xml.nil? + account.display_name = author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content unless author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil? + account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content unless author_xml.at_xpath('./poco:note').nil? + account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'].blank? end + old_hub_url = account.hub_url + account.hub_url = hub_link['href'] if !hub_link.nil? && !hub_link['href'].blank? && (hub_link['href'] != old_hub_url) account.save! + + SubscribeService.new.call(account) if resubscribe && (account.hub_url != old_hub_url) end end diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby index d7b2201d4..558c777f0 100644 --- a/app/views/accounts/show.atom.ruby +++ b/app/views/accounts/show.atom.ruby @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Nokogiri::XML::Builder.new do |xml| feed(xml) do simple_id xml, account_url(@account, format: 'atom') @@ -12,6 +14,7 @@ Nokogiri::XML::Builder.new do |xml| link_alternate xml, TagManager.instance.url_for(@account) link_self xml, account_url(@account, format: 'atom') + link_hub xml, api_push_url link_hub xml, Rails.configuration.x.hub_url link_salmon xml, api_salmon_url(@account.id) diff --git a/app/views/admin/pubsubhubbub/index.html.haml b/app/views/admin/pubsubhubbub/index.html.haml new file mode 100644 index 000000000..bb897eb89 --- /dev/null +++ b/app/views/admin/pubsubhubbub/index.html.haml @@ -0,0 +1,20 @@ +%table.table + %thead + %tr + %th Topic + %th Callback URL + %th Confirmed + %th Expires in + %tbody + - @subscriptions.each do |subscription| + %tr + %td + %samp= subscription.account.acct + %td + %samp= subscription.callback_url + %td + - if subscription.confirmed? + %i.fa.fa-check + %td= distance_of_time_in_words(Time.now, subscription.expires_at) + += will_paginate @subscriptions, pagination_options diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl index 90457eca9..579c47b26 100644 --- a/app/views/api/v1/statuses/_show.rabl +++ b/app/views/api/v1/statuses/_show.rabl @@ -1,4 +1,4 @@ -attributes :id, :created_at, :in_reply_to_id +attributes :id, :created_at, :in_reply_to_id, :sensitive node(:uri) { |status| TagManager.instance.uri_for(status) } node(:content) { |status| Formatter.instance.format(status) } diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 693702ff7..db5b9fb48 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -12,6 +12,10 @@ = ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label + = f.simple_fields_for :interactions, current_user.settings(:interactions) do |ff| + = ff.input :must_be_follower, as: :boolean, wrapper: :with_label + = ff.input :must_be_following, as: :boolean, wrapper: :with_label + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index b16258679..2c6de32d9 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -1,5 +1,8 @@ - content_for :header_tags do %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/ + %meta{ name: 'og:site_name', content: 'Mastodon' }/ + %meta{ name: 'og:type', content: 'article' }/ + %meta{ name: 'og:article:author', content: @account.username }/ .activity-stream.activity-stream-headless = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true } diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb index 3b11a4c5e..b31cd0aaf 100644 --- a/app/workers/processing_worker.rb +++ b/app/workers/processing_worker.rb @@ -2,6 +2,8 @@ class ProcessingWorker include Sidekiq::Worker + + sidekiq_options backtrace: true def perform(account_id, body) ProcessFeedService.new.call(body, Account.find(account_id)) diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb new file mode 100644 index 000000000..489bd8359 --- /dev/null +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Pubsubhubbub::ConfirmationWorker + include Sidekiq::Worker + include RoutingHelper + + sidekiq_options queue: 'push' + + def perform(subscription_id, mode, secret = nil, lease_seconds = nil) + subscription = Subscription.find(subscription_id) + challenge = SecureRandom.hex + + subscription.secret = secret + subscription.lease_seconds = lease_seconds + subscription.confirmed = true + + response = HTTP.headers(user_agent: 'Mastodon/PubSubHubbub') + .timeout(:per_operation, write: 20, connect: 20, read: 50) + .get(subscription.callback_url, params: { + 'hub.topic' => account_url(subscription.account, format: :atom), + 'hub.mode' => mode, + 'hub.challenge' => challenge, + 'hub.lease_seconds' => subscription.lease_seconds, + }) + + body = response.body.to_s + + Rails.logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{body}" + + if mode == 'subscribe' && body == challenge + subscription.save! + elsif (mode == 'unsubscribe' && body == challenge) || !subscription.confirmed? + subscription.destroy! + end + end +end diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb new file mode 100644 index 000000000..6d526c2b1 --- /dev/null +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Pubsubhubbub::DeliveryWorker + include Sidekiq::Worker + include RoutingHelper + + sidekiq_options queue: 'push' + + def perform(subscription_id, payload) + subscription = Subscription.find(subscription_id) + headers = {} + + headers['User-Agent'] = 'Mastodon/PubSubHubbub' + headers['Link'] = LinkHeader.new([[api_push_url, [%w(rel hub)]], [account_url(subscription.account, format: :atom), [%w(rel self)]]]).to_s + headers['X-Hub-Signature'] = signature(subscription.secret, payload) unless subscription.secret.blank? + + response = HTTP.timeout(:per_operation, write: 50, connect: 20, read: 50) + .headers(headers) + .post(subscription.callback_url, body: payload) + + raise "Delivery failed for #{subscription.callback_url}: HTTP #{response.code}" unless response.code > 199 && response.code < 300 + end + + private + + def signature(secret, payload) + hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret, payload) + "sha1=#{hmac}" + end +end diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb new file mode 100644 index 000000000..b0ddc71c1 --- /dev/null +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Pubsubhubbub::DistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(stream_entry_id) + stream_entry = StreamEntry.find(stream_entry_id) + account = stream_entry.account + renderer = AccountsController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https) + payload = renderer.render(:show, assigns: { account: account, entries: [stream_entry] }, formats: [:atom]) + + Subscription.where(account: account).active.select('id').find_each do |subscription| + Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload) + end + end +end diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb new file mode 100644 index 000000000..7470c54f5 --- /dev/null +++ b/app/workers/removal_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemovalWorker + include Sidekiq::Worker + + def perform(status_id) + RemoveStatusService.new.call(Status.find(status_id)) + end +end \ No newline at end of file diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb index 24fb94012..0903ca487 100644 --- a/app/workers/salmon_worker.rb +++ b/app/workers/salmon_worker.rb @@ -2,6 +2,8 @@ class SalmonWorker include Sidekiq::Worker + + sidekiq_options backtrace: true def perform(account_id, body) ProcessInteractionService.new.call(body, Account.find(account_id)) diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 700161989..84eae73be 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -7,9 +7,9 @@ class ThreadResolveWorker child_status = Status.find(child_status_id) parent_status = FetchRemoteStatusService.new.call(parent_url) - unless parent_status.nil? - child_status.thread = parent_status - child_status.save! - end + return if parent_status.nil? + + child_status.thread = parent_status + child_status.save! end end diff --git a/config/application.rb b/config/application.rb index 7ba13bfbe..1e5fd9c7c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -20,7 +20,7 @@ module Mastodon # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] - config.i18n.available_locales = [:en, :de, :es, :fr] + config.i18n.available_locales = [:en, :de, :es, :pt, :fr, :hu] config.i18n.default_locale = :en # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb') diff --git a/config/environments/production.rb b/config/environments/production.rb index 7f13fcf6b..9254d494c 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -13,7 +13,7 @@ Rails.application.configure do # Full error reports are disabled and caching is turned on. config.consider_all_requests_local = false config.action_controller.perform_caching = true - config.action_controller.asset_host = ENV['CDN_HOST'] + config.action_controller.asset_host = ENV['CDN_HOST'] if ENV.key?('CDN_HOST') # Disable serving static files from the `/public` folder by default since # Apache or NGINX already handles this. @@ -30,7 +30,7 @@ Rails.application.configure do # Specifies the header that your server uses for sending files. # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache - # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX + config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = false @@ -50,7 +50,8 @@ Rails.application.configure do host: ENV.fetch('REDIS_HOST') { 'localhost' }, port: ENV.fetch('REDIS_PORT') { 6379 }, db: 0, - namespace: 'cache' + namespace: 'cache', + expires_in: 20.minutes } # Enable serving of images, stylesheets, and JavaScripts from an asset server. diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index d345ce6c0..4dc6985b7 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -30,9 +30,6 @@ search: - app/assets/fonts - app/assets/videos -ignore_missing: - - '{devise,simple_form}.*' - ignore_unused: - 'activerecord.attributes.*' - '{devise,will_paginate,doorkeeper}.*' diff --git a/config/initializers/neography.rb b/config/initializers/neography.rb deleted file mode 100644 index bd6ead3b0..000000000 --- a/config/initializers/neography.rb +++ /dev/null @@ -1,5 +0,0 @@ -Neography.configure do |config| - config.protocol = "http" - config.server = ENV.fetch('NEO4J_HOST') { 'localhost' } - config.port = ENV.fetch('NEO4J_PORT') { 7474 } -end diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb index 3dd501b65..4ba432b6a 100644 --- a/config/initializers/ostatus.rb +++ b/config/initializers/ostatus.rb @@ -1,14 +1,16 @@ -port = ENV.fetch('PORT') { 3000 } - +port = ENV.fetch('PORT') { 3000 } +host = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" } +https = ENV['LOCAL_HTTPS'] == 'true' + Rails.application.configure do - config.x.local_domain = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" } - config.x.hub_url = ENV.fetch('HUB_URL') { 'https://pubsubhubbub.superfeedr.com' } - config.x.use_https = ENV['LOCAL_HTTPS'] == 'true' + config.x.local_domain = host + config.x.hub_url = ENV.fetch('HUB_URL') { 'https://pubsubhubbub.superfeedr.com' } + config.x.use_https = https config.x.use_s3 = ENV['S3_ENABLED'] == 'true' - config.action_mailer.default_url_options = { host: config.x.local_domain, protocol: config.x.use_https ? 'https://' : 'http://', trailing_slash: false } + config.action_mailer.default_url_options = { host: host, protocol: https ? 'https://' : 'http://', trailing_slash: false } if Rails.env.production? - config.action_cable.allowed_request_origins = ["http#{config.x.use_https ? 's' : ''}://#{config.x.local_domain}"] + config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"] end end diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index 643c5d384..80effc05e 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -1,4 +1,6 @@ if ENV['S3_ENABLED'] == 'true' + Aws.eager_autoload!(services: %w(S3)) + Paperclip::Attachment.default_options[:storage] = :s3 Paperclip::Attachment.default_options[:s3_protocol] = 'https' Paperclip::Attachment.default_options[:url] = ':s3_domain_url' @@ -9,6 +11,6 @@ if ENV['S3_ENABLED'] == 'true' bucket: ENV.fetch('S3_BUCKET'), access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'), secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY'), - s3_region: ENV.fetch('S3_REGION') + s3_region: ENV.fetch('S3_REGION'), } end diff --git a/config/initializers/rack-attack.rb b/config/initializers/rack-attack.rb index 6d9286e66..3f0ee1d7a 100644 --- a/config/initializers/rack-attack.rb +++ b/config/initializers/rack-attack.rb @@ -1,7 +1,7 @@ class Rack::Attack # Rate limits for the API throttle('api', limit: 150, period: 5.minutes) do |req| - req.ip if req.path.match(/\A\/api\//) + req.ip if req.path.match(/\A\/api\/v/) end self.throttled_response = lambda do |env| @@ -11,7 +11,7 @@ class Rack::Attack headers = { 'X-RateLimit-Limit' => match_data[:limit].to_s, 'X-RateLimit-Remaining' => '0', - 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s + 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6) } [429, headers, [{ error: 'Throttled' }.to_json]] diff --git a/config/initializers/timeout.rb b/config/initializers/timeout.rb new file mode 100644 index 000000000..8b7311e39 --- /dev/null +++ b/config/initializers/timeout.rb @@ -0,0 +1 @@ +Rack::Timeout.timeout = 30 \ No newline at end of file diff --git a/config/locales/devise.hu.yml b/config/locales/devise.hu.yml new file mode 100644 index 000000000..2eb7da45c --- /dev/null +++ b/config/locales/devise.hu.yml @@ -0,0 +1,61 @@ +--- +hu: + devise: + confirmations: + confirmed: Az e-mail címed sikeresen meg lett erősítve. + send_instructions: Pár percen belül kapni fogsz egy e-mailt az e-mail címed megerősítéséhez szükséges lépésekről. + send_paranoid_instructions: Ha az e-mail címed létezik az adatbázisunkban, pár percen belül kapni fogsz egy e-mailt az e-mail címed megerősítéséhez szükséges lépésekről. + failure: + already_authenticated: Már bejelentkeztél + inactive: Fiókod még nem lett aktiválva. + invalid: Helytelen %{authentication_keys} vagy jelszó. + last_attempt: Már csak egy próbálkozásod maradt mielőtt a fiókod lezárásra kerül. + locked: Fiókod le van zárva. + not_found_in_database: Helytelen %{authentication_keys} vagy jelszó. + timeout: A munkamenet lejárt. Jelentkezz be újra a folytatáshoz. + unauthenticated: A folytatás előtt be kell jelentkezned. + unconfirmed: A folytatás előtt meg kell erősítened az e-mail címed. + mailer: + confirmation_instructions: + subject: 'Mastodon: Megerősítési lépések' + password_change: + subject: 'Mastodon: Jelszó megváltoztatva' + reset_password_instructions: + subject: 'Mastodon: Jelszó visszaállítási lépések' + unlock_instructions: + subject: 'Mastodon: Feloldási lépések' + omniauth_callbacks: + failure: "%{kind} nem hitelesíthető, mert %{reason}." + success: Sikeres hitelesítés %{kind} fiókról. + passwords: + no_token: Nem férhetsz hozzá az oldalhoz jelszó visszaállító e-mail nélkül. Ha egy jelszó visszaállító e-mail hozott ide, ellenőrizd, hogy a megadott teljes URL-t használd. + send_instructions: Pár percen belül kapni fogsz egy e-mailt arról, hogy hogyan tudod visszaállítani a jelszavadat. + send_paranoid_instructions: Ha létezik az e-mail cím, pár percen belül kapni fogsz egy e-mailt arról, hogy hogyan tudod visszaállítani a jelszavadat. + updated: Jelszavad sikeresen frissült. Bejelentkeztél. + updated_not_active: Jelszavad sikeresen meg lett változtatva. + registrations: + destroyed: Viszlát! A fiókod sikeresen törölve. Reméljük hamarosan viszontláthatunk. + signed_up: Üdvözlünk! Sikeresen regisztráltál. + signed_up_but_inactive: Sikeresen regisztráltál. Ennek ellenére nem tudunk beléptetni, ugyanis a fiókod még nem lett aktiválva. + signed_up_but_locked: Sikeresen regisztráltál. Ennek ellenére nem tudunk beléptetni, ugyanis a fiókod le lett zárva. + signed_up_but_unconfirmed: Egy üzenet a megerősítési linkkel kiküldésre került az e-mail címedre. Kérjük használd a linket a fiókod aktiválásához. + update_needs_confirmation: Sikeresen frissítetted a fiókodat, de szükségünk van az e-mail címed megerősítésére. Kérlek ellenőrizd az e-mailedet és kövesd a levélben szereplő megerősítési linket az e-mail címed megerősítéséhez. + updated: Fiókod frissítése sikeres. + sessions: + already_signed_out: Sikeres kijelenkezés. + signed_in: Sikeres bejelentkezés. + signed_out: Sikeres kijelentkezés. + unlocks: + send_instructions: Pár percen belül egy e-mailt fogsz kapni a feloldáshoz szükséges lépésekkel. + send_paranoid_instructions: Ha a fiókod létezik, pár percen belül egy e-mailt fogsz kapni a feloldáshoz szükséges lépésekkel. + unlocked: A fiókod sikeresen fel lett oldva. Jelentkezz be a folytatáshoz. + errors: + messages: + already_confirmed: már meg lett erősítve, kérjük jelentkezz be + confirmation_period_expired: "%{period} belül kellett megerősíteni, kérjük igényelj újat" + expired: lejárt, kérjük igényelj újat + not_found: nem található + not_locked: nincs lezárva + not_saved: + one: '1 hiba megakadályozta %{resource} mentését:' + other: "%{count} számú hiba megakadályozta %{resource} mentését:" diff --git a/config/locales/doorkeeper.fr.yml b/config/locales/doorkeeper.fr.yml index c04e378b6..6f3c0864a 100644 --- a/config/locales/doorkeeper.fr.yml +++ b/config/locales/doorkeeper.fr.yml @@ -31,7 +31,7 @@ fr: help: native_redirect_uri: Utiliser %{native_redirect_uri} pour les tests locaux redirect_uri: Utiliser une ligne par URL - scopes: + scopes: Séparer les portées avec des espaces. Laisser vide pour utiliser les portées par défaut. index: callback_url: URL de retour d'appel name: Nom @@ -43,7 +43,7 @@ fr: actions: Actions application_id: ID de l'application callback_urls: URL du retour d'appel - scopes: + scopes: Portées secret: Secret title: 'Application : %{name}' authorizations: @@ -77,7 +77,7 @@ fr: invalid_redirect_uri: L'URL de redirection n'est pas valide. invalid_request: La demande manque un paramètre requis, inclut une valeur de paramètre non prise en charge, ou est autrement mal formée. invalid_resource_owner: Les identifiants fournis du propriétaire de la ressource ne sont pas valides, ou le propriétaire de la ressource ne peut être trouvé - invalid_scope: Le scope demandé n'est pas valide, est inconnu, ou est mal formé. + invalid_scope: La portée demandée n'est pas valide, est inconnue, ou est mal formée. invalid_token: expired: Le jeton d'accès a expiré revoked: Le jeton d'accès a été annulé @@ -107,6 +107,6 @@ fr: application: title: Autorisation OAuth requise scopes: - follow: - read: - write: + follow: s’abonner, se désabonner, bloquer, et débloquer des comptes + read: lire les données de votre compte + write: poster en tant que vous diff --git a/config/locales/doorkeeper.hu.yml b/config/locales/doorkeeper.hu.yml new file mode 100644 index 000000000..b1c6dd6c9 --- /dev/null +++ b/config/locales/doorkeeper.hu.yml @@ -0,0 +1,112 @@ +--- +hu: + activerecord: + attributes: + doorkeeper/application: + name: Név + redirect_uri: Visszairányító URI + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: nem tartalmazhat töredéket. + invalid_uri: érvényes URI-nak kell lennie. + relative_uri: abszolút URI-nak kell lennie. + secured_uri: HTTPS/SSL URI-nak kell lennie. + doorkeeper: + applications: + buttons: + authorize: Engedélyezés + cancel: Mégsem + destroy: Törlés + edit: Szerkesztés + submit: Elküldés + confirmations: + destroy: Biztos vagy benne? + edit: + title: Alkalmazás szerkesztése + form: + error: Hoppá! Ellenőrizd az űrlapot az esetleges hibák miatt + help: + native_redirect_uri: Használj %{native_redirect_uri} a helyi tesztekhez + redirect_uri: Egy sor URI-nként + scopes: A nézeteket szóközzel válaszd el. Hagyd üresen az alapértelmezett nézetekhez. + index: + callback_url: Callback URL + name: Név + new: Új alkalmazás + title: Alkalmazásod + new: + title: Új alkalmazás + show: + actions: Műveletek + application_id: Alkalmazás azonosító + callback_urls: Callback urlek + scopes: Nézetek + secret: Titok + title: 'Alkalmazás: %{name}' + authorizations: + buttons: + authorize: Engedélyezés + deny: Tiltás + error: + title: Hiba történt + new: + able_to: Képes lesz + prompt: "%{client_name} nevű alkalmazás engedélyt kér a fiókodhoz való hozzáféréshez." + title: Engedély szükséges + show: + title: Engedély kódja + authorized_applications: + buttons: + revoke: Visszavonás + confirmations: + revoke: Biztos vagy benne? + index: + application: Alkalmazás + created_at: Készítve + date_format: "%Y-%m-%d %H:%M:%S" + title: Engedélyezett alkalmazásaid + errors: + messages: + access_denied: Az erőforrás tulajdonosa vagy hitelesítő kiszolgálója megtakadta a kérést. + credential_flow_not_configured: Az erőforrás tulajdonos jelszóadatainak átadása megszakadt, mert a Doorkeeper.configure.resource_owner_from_credentials beállítatlan. + invalid_client: A kliens hitelesítése megszakadt, mert a ismeretlen a kliens, kliens nem küldött hitelesítést, vagy ismeretlen a kliens + invalid_grant: A biztosított hitelesítés érvénytelen, lejárt, visszavont, vagy nem egyezik a hitelesítéi kérésben használt URIval, vagy más kliensnek lett címezve. + invalid_redirect_uri: A redirect uri nem valós. + invalid_request: A kérésből hiányzik egy szükséges paraméter, nem támogatott paramétert tartalmaz, vagy egyéb módon hibás. + invalid_resource_owner: A biztosított erőforrás tulajdonosának hitelesítő adatai nem valósak, vagy az erőforrás tulajdonosa nem található. + invalid_scope: A kért nézet érvénytelen, ismeretlen, vagy hibás. + invalid_token: + expired: Hozzáférési kulcs lejárt + revoked: Hozzáférési kulcs vissza lett vonva + unknown: Hozzáférési kulcs érvénytelen + resource_owner_authenticator_not_configured: Erőforrás tulajdonos keresés megszakadt, ugyanis a Doorkeeper.configure.resource_owner_authenticator beállítatlan. + server_error: Hitelesítő szervert váratlan esemény érte, mely meggátolta a kérés teljesítését. + temporarily_unavailable: A hitelesítő szerver jelenleg nem tudja teljesíteni a kérést egy átmeneti túlterheltség vagy a kiszolgáló karbantartása miatt. + unauthorized_client: A kliens nincs feljogosítva a kérés teljesítésére. + unsupported_grant_type: A hitelesítés módja nem támogatott a hitelesítő kiszolgálón. + unsupported_response_type: A hitelesítő kiszolgáló nem támogatja ezt a választ. + flash: + applications: + create: + notice: Alkalmazás létrehozva. + destroy: + notice: Alkalmazás törölve. + update: + notice: Alkalmazás frissítve. + authorized_applications: + destroy: + notice: Alkalmazás visszavonva. + layouts: + admin: + nav: + applications: Alkalmazások + oauth2_provider: OAuth2 szolgáltató + application: + title: OAuth engedély szükséges + scopes: + follow: fiókok követése, blokkoláse, blokkolás feloldása és követés abbahagyása + read: fiókod adatainak olvasása + write: bejegyzés írása a nevedben diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c9258381c..f78cd0de5 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -7,44 +7,44 @@ fr: source_code: "Code source" terms: "Conditions d’utilisation" accounts: - follow: "S’abonner" + follow: "Suivre" followers: "Abonnés" following: "Abonnements" - nothing_here: "Rien à voir ici !" - people_followed_by: "Personnes auxquelles %{name} est abonné⋅e" - people_who_follow: "Personnes abonnées à %{name}" + nothing_here: "Rien à voir ici !" + people_followed_by: "Personnes suivies par %{name}" + people_who_follow: "Personnes qui suivent %{name}" posts: "Statuts" - unfollow: "Se désabonner" + unfollow: "Ne plus suivre" application_mailer: signature: "Notifications de Mastodon depuis %{instance}" auth: change_password: "Changer de mot de passe" - didnt_get_confirmation: "Vous n’avez pas reçu les consignes de confirmation ?" - forgot_password: "Mode passe oublié ?" + didnt_get_confirmation: "Vous n’avez pas reçu les consignes de confirmation ?" + forgot_password: "Mode passe oublié ?" login: "Se connecter" register: "S’inscrire" resend_confirmation: "Envoyer à nouveau les consignes de confirmation" reset_password: "Réinitialiser le mot de passe" set_new_password: "Établir le nouveau mot de passe" generic: - changes_saved_msg: "Les modifications ont été enregistrées avec succès !" + changes_saved_msg: "Les modifications ont été enregistrées avec succès !" powered_by: "propulsé par %{link}" save_changes: "Enregistrer les modifications" validation_errors: - one: "Quelque chose ne va pas ! Vérifiez l’erreur ci-dessous." - other: "Quelques choses ne vont pas ! Vérifiez les erreurs ci-dessous." + one: "Quelque chose ne va pas ! Vérifiez l’erreur ci-dessous." + other: "Quelques choses ne vont pas ! Vérifiez les erreurs ci-dessous." notification_mailer: favourite: - body: "%{name} a ajouté votre statut à ses favoris :" + body: "%{name} a ajouté votre statut à ses favoris :" subject: "%{name} a ajouté votre statut à ses favoris" follow: - body: "%{name} s’est abonné⋅e à vos statuts !" - subject: "%{name} s’est abonné⋅e à vos statuts" + body: "%{name} vous suit !" + subject: "%{name} vous suit" mention: - body: "%{name} vous a mentionné⋅e dans :" + body: "%{name} vous a mentionné⋅e dans :" subject: "%{name} vous a mentionné⋅e" reblog: - body: "%{name} a partagé votre statut :" + body: "%{name} a partagé votre statut :" subject: "%{name} a partagé votre statut" pagination: next: "Suivant" @@ -54,6 +54,6 @@ fr: preferences: "Préférences" stream_entries: favourited: "a ajouté à ses favoris un statut de" - is_now_following: "s’est abonné⋅e à" + is_now_following: "suit désormais" will_paginate: page_gap: "…" diff --git a/config/locales/hu.yml b/config/locales/hu.yml new file mode 100644 index 000000000..d891b2b28 --- /dev/null +++ b/config/locales/hu.yml @@ -0,0 +1,59 @@ +--- +hu: + about: + about_instance: "<em>%{instance}</em> egy Mastodon másolat." + about_mastodon: Mastodon egy <em>szabad, nyílt forráskódú</em> szociális hálózati kiszolgálo. Egy <em>központosítatlan</em> alternatíva a kereskedelmi platformokra, elkerüli a kommunikációd monopolizációját veszélyét. Bárki futtathatja a Mastodon-t és részt vehet a <em>szociális hálózatban</em>. + get_started: Első lépések + source_code: Forráskód + terms: Feltételek + accounts: + follow: Követés + followers: Követők + following: Követed őket + nothing_here: Nincs itt semmi! + people_followed_by: "%{name} követett személyei" + people_who_follow: "%{name} követői" + posts: Bejegyzések + unfollow: Követés abbahagyása + application_mailer: + signature: "%{instance} Mastodon értesítései" + auth: + change_password: Jelszó változtatása + didnt_get_confirmation: Nem kaptad meg a megerősítési lépéseket? + forgot_password: Elfelejtetted a jelszavad? + login: Belépés + register: Regisztráció + resend_confirmation: Megerősítési lépések újraküldése + reset_password: Jelszó visszaállítása + set_new_password: Új jelszó beállítása + generic: + changes_saved_msg: Változások sikeresen elmentve! + powered_by: powered by %{link} + save_changes: Változások mentése + validation_errors: + one: Valami nincs rendjén! Kérlek tekintsd meg a hibát alant + other: Valami nincs rendjén! Kérlek tekintsd meg a %{count} darab hibát alant. + notification_mailer: + favourite: + body: 'Az állapotodat kedvencnek jelölte %{name}:' + subject: "%{name} kedvencnek jelölte az állapotod" + follow: + body: "%{name} mostantól követ téged!" + subject: "%{name} mostantól követ téged" + mention: + body: '%{name} megemlített téged:' + subject: "%{name} megemlített téged" + reblog: + body: 'Az állapotod reblogolta %{name}:' + subject: "%{name} reblogolta az állapotod" + pagination: + next: Következő + prev: Előző + settings: + edit_profile: Profil szerkesztése + preferences: Beállítások + stream_entries: + favourited: kedvencnek jelölték a bejegyzésedet + is_now_following: mostantól követ + will_paginate: + page_gap: "…" diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index 36c5141a2..47e30ccb4 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -16,6 +16,9 @@ de: password: Passwort silenced: Öffentliche Beiträge nicht auflisten username: Nutzername + interactions: + must_be_follower: Benachrichtigungen von nicht-Folgern blockieren + must_be_following: Benachrichtigungen von Nutzern blockieren, denen ich nicht folge notification_emails: favourite: E-mail senden, wenn jemand meinen Beitrag favorisiert follow: E-mail senden, wenn mir jemand folgt diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index a7d958c06..1e975af14 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -16,6 +16,9 @@ en: password: Password silenced: Unlisted mode username: Username + interactions: + must_be_follower: Block notifications from non-followers + must_be_following: Block notifications from people you don't follow notification_emails: favourite: Send e-mail when someone favourites your status follow: Send e-mail when someone follows you diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml index 7333c9e11..73905a7b3 100644 --- a/config/locales/simple_form.fr.yml +++ b/config/locales/simple_form.fr.yml @@ -15,12 +15,15 @@ fr: note: Présentation password: Mot de passe silenced: Ne pas apparaître dans le fil public - username: Nom d’utilisateur + username: Identifiant notification_emails: - favourite: Envoyer un courriel lorsque quelqu’un ajoute un de mes statuts à ses favoris - follow: Envoyer un courriel lorsque quelqu’un s’abonne à mes statuts + favourite: Envoyer un courriel lorsque quelqu’un ajoute mes statut à ses favoris + follow: Envoyer un courriel lorsque quelqu’un me suit mention: Envoyer un courriel lorsque quelqu’un me mentionne - reblog: Envoyer un courriel lorsque quelqu’un partage un de mes statuts + reblog: Envoyer un courriel lorsque quelqu’un partage mes statuts + interactions: + must_be_follower: Masquer les notifications des personnes qui ne vous suivent pas + must_be_following: Masquer les notifications des personnes que vous ne suivez pas 'no': Non required: mark: "*" diff --git a/config/locales/simple_form.hu.yml b/config/locales/simple_form.hu.yml new file mode 100644 index 000000000..39c450087 --- /dev/null +++ b/config/locales/simple_form.hu.yml @@ -0,0 +1,28 @@ +--- +hu: + simple_form: + labels: + defaults: + avatar: Profilkép + confirm_new_password: Új jelszó megerősítése + confirm_password: Jelszó megerősítése + current_password: Jelenlegi jelszó + display_name: Megjelenített név + email: E-mail cím + header: Fejléc + locale: Nyelv + new_password: Új jelszó + note: Önéletrajz + password: Jelszó + silenced: Listázatlan mód + username: Felhasználónév + notification_emails: + favourite: E-mail küldése amikor valaki kedvencnek jelöli az állapotod + follow: E-mail küldése amikor valaki követni kezd téged + mention: E-mail küldése amikor valaki megemlít téged + reblog: E-mail küldése amikor valaki reblogolja az állapotod + 'no': 'Nem' + required: + mark: "*" + text: kötelező + 'yes': 'Igen' diff --git a/config/routes.rb b/config/routes.rb index 00185c5e8..cd544a62b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'sidekiq/web' Rails.application.routes.draw do - mount ActionCable.server => '/cable' + mount ActionCable.server, at: 'cable' authenticate :user, lambda { |u| u.admin? } do mount Sidekiq::Web, at: 'sidekiq' @@ -19,7 +21,7 @@ Rails.application.routes.draw do sessions: 'auth/sessions', registrations: 'auth/registrations', passwords: 'auth/passwords', - confirmations: 'auth/confirmations' + confirmations: 'auth/confirmations', } resources :accounts, path: 'users', only: [:show], param: :username do @@ -42,11 +44,18 @@ Rails.application.routes.draw do resources :media, only: [:show] resources :tags, only: [:show] + namespace :admin do + resources :pubsubhubbub, only: [:index] + end + namespace :api do - # PubSubHubbub + # PubSubHubbub outgoing subscriptions resources :subscriptions, only: [:show] post '/subscriptions/:id', to: 'subscriptions#update' + # PubSubHubbub incoming subscriptions + post '/push', to: 'push#update', as: :push + # Salmon post '/salmon/:id', to: 'salmon#update', as: :salmon @@ -80,7 +89,6 @@ Rails.application.routes.draw do collection do get :relationships get :verify_credentials - get :suggestions get :search end @@ -88,7 +96,6 @@ Rails.application.routes.draw do get :statuses get :followers get :following - get :common_followers post :follow post :unfollow diff --git a/db/migrate/20161123093447_add_sensitive_to_statuses.rb b/db/migrate/20161123093447_add_sensitive_to_statuses.rb new file mode 100644 index 000000000..109f761ed --- /dev/null +++ b/db/migrate/20161123093447_add_sensitive_to_statuses.rb @@ -0,0 +1,5 @@ +class AddSensitiveToStatuses < ActiveRecord::Migration[5.0] + def change + add_column :statuses, :sensitive, :boolean, default: false + end +end diff --git a/db/migrate/20161128103007_create_subscriptions.rb b/db/migrate/20161128103007_create_subscriptions.rb new file mode 100644 index 000000000..46443680a --- /dev/null +++ b/db/migrate/20161128103007_create_subscriptions.rb @@ -0,0 +1,15 @@ +class CreateSubscriptions < ActiveRecord::Migration[5.0] + def change + create_table :subscriptions do |t| + t.string :callback_url, null: false, default: '' + t.string :secret + t.datetime :expires_at, null: true, default: nil + t.boolean :confirmed, null: false, default: false + t.integer :account_id, null: false + + t.timestamps + end + + add_index :subscriptions, [:callback_url, :account_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 42e4e081c..2c0e6de5b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161122163057) do +ActiveRecord::Schema.define(version: 20161128103007) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -143,6 +143,19 @@ ActiveRecord::Schema.define(version: 20161122163057) do t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree end + create_table "pubsubhubbub_subscriptions", force: :cascade do |t| + t.string "topic", default: "", null: false + t.string "callback", default: "", null: false + t.string "mode", default: "", null: false + t.string "challenge", default: "", null: false + t.string "secret" + t.boolean "confirmed", default: false, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["topic", "callback"], name: "index_pubsubhubbub_subscriptions_on_topic_and_callback", unique: true, using: :btree + end + create_table "settings", force: :cascade do |t| t.string "var", null: false t.text "value" @@ -155,13 +168,14 @@ ActiveRecord::Schema.define(version: 20161122163057) do create_table "statuses", force: :cascade do |t| t.string "uri" - t.integer "account_id", null: false - t.text "text", default: "", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.integer "account_id", null: false + t.text "text", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.integer "in_reply_to_id" t.integer "reblog_of_id" t.string "url" + t.boolean "sensitive", default: false t.index ["account_id"], name: "index_statuses_on_account_id", using: :btree t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", using: :btree t.index ["reblog_of_id"], name: "index_statuses_on_reblog_of_id", using: :btree @@ -184,6 +198,17 @@ ActiveRecord::Schema.define(version: 20161122163057) do t.index ["activity_id", "activity_type"], name: "index_stream_entries_on_activity_id_and_activity_type", using: :btree end + create_table "subscriptions", force: :cascade do |t| + t.string "callback_url", default: "", null: false + t.string "secret" + t.datetime "expires_at" + t.boolean "confirmed", default: false, null: false + t.integer "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["callback_url", "account_id"], name: "index_subscriptions_on_callback_url_and_account_id", unique: true, using: :btree + end + create_table "tags", force: :cascade do |t| t.string "name", default: "", null: false t.datetime "created_at", null: false diff --git a/docker-compose.yml b/docker-compose.yml index 9f9126805..e1f1f1c4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,16 +6,9 @@ services: redis: restart: always image: redis - neo4j: - restart: always - build: - context: . - dockerfile: Dockerfile.neo4j web: restart: always - build: - context: . - dockerfile: Dockerfile.app + build: . env_file: .env.production command: bundle exec rails s -p 3000 -b '0.0.0.0' ports: @@ -23,20 +16,16 @@ services: depends_on: - db - redis - - neo4j volumes: - ./public/assets:/mastodon/public/assets - ./public/system:/mastodon/public/system sidekiq: restart: always - build: - context: . - dockerfile: Dockerfile.app + build: . env_file: .env.production - command: bundle exec sidekiq -q default -q mailers + command: bundle exec sidekiq -q default -q mailers -q push depends_on: - db - redis - - neo4j volumes: - ./public/system:/mastodon/public/system diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index 58bafff66..8730a64cd 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -36,16 +36,17 @@ namespace :mastodon do end namespace :feeds do - desc 'Clears all timelines so that they would be regenerated on next hit' + desc 'Clear timelines of inactive users' task clear: :environment do - Redis.current.keys('feed:*').each { |key| Redis.current.del(key) } + User.where('current_sign_in_at < ?', 14.days.ago).find_each do |user| + Redis.current.del(FeedManager.instance.key(:home, user.account_id)) + Redis.current.del(FeedManager.instance.key(:mentions, user.account_id)) + end end - end - namespace :graphs do - desc 'Syncs all follow relationships to Neo4J' - task sync: :environment do - Follow.find_each(&:sync!) + desc 'Clears all timelines so that they would be regenerated on next hit' + task clear_all: :environment do + Redis.current.keys('feed:*').each { |key| Redis.current.del(key) } end end end diff --git a/package.json b/package.json index a9f816b83..3bd2eacd3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@kadira/storybook": "^2.24.0", "axios": "^0.14.0", "babel-plugin-react-transform": "^2.0.2", + "babel-plugin-transform-decorators-legacy": "^1.3.4", "babel-plugin-transform-object-rest-spread": "^6.8.0", "babel-preset-es2015": "^6.13.2", "babel-preset-react": "^6.11.1", @@ -16,37 +17,39 @@ "browserify-incremental": "^3.1.1", "chai": "^3.5.0", "chai-enzyme": "^0.5.2", + "emojione": "^2.2.6", "enzyme": "^2.4.1", "es6-promise": "^3.2.1", + "http-link-header": "^0.5.0", "immutable": "^3.8.1", + "intl": "^1.2.5", "jsdom": "^9.6.0", "mocha": "^3.1.1", "react": "^15.3.2", "react-addons-perf": "^15.3.2", "react-addons-pure-render-mixin": "^15.3.1", "react-addons-test-utils": "^15.3.2", + "react-autosuggest": "^7.0.1", + "react-decoration": "^1.4.0", "react-dom": "^15.3.0", "react-immutable-proptypes": "^2.1.0", + "react-intl": "^2.1.5", + "react-motion": "^0.4.5", "react-notification": "^6.4.0", "react-proxy": "^1.1.8", "react-redux": "^5.0.0-beta.3", "react-redux-loading-bar": "^2.4.1", + "react-responsive": "^1.1.5", "react-router": "^2.8.0", + "react-router-scroll": "^0.3.2", "react-simple-dropdown": "^1.1.4", "redux": "^3.5.2", "redux-immutable": "^3.0.8", "redux-thunk": "^2.1.0", "reselect": "^2.5.4", - "sinon": "^1.17.6", - "babel-plugin-transform-decorators-legacy": "^1.3.4", - "emojione": "^2.2.6", - "http-link-header": "^0.5.0", - "intl": "^1.2.5", - "react-autosuggest": "^7.0.1", - "react-decoration": "^1.4.0", - "react-intl": "^2.1.5", - "react-motion": "^0.4.5", - "react-responsive": "^1.1.5", - "react-router-scroll": "^0.3.2" + "sinon": "^1.17.6" + }, + "dependencies": { + "react-toggle": "^2.1.1" } } diff --git a/spec/controllers/admin/pubsubhubbub_controller_spec.rb b/spec/controllers/admin/pubsubhubbub_controller_spec.rb new file mode 100644 index 000000000..068bd09a6 --- /dev/null +++ b/spec/controllers/admin/pubsubhubbub_controller_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Admin::PubsubhubbubController, type: :controller do + describe 'GET #index' do + before do + sign_in Fabricate(:user, admin: true), scope: :user + end + + it 'returns http success' do + get :index + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/controllers/api/push_controller_spec.rb b/spec/controllers/api/push_controller_spec.rb new file mode 100644 index 000000000..e699006f7 --- /dev/null +++ b/spec/controllers/api/push_controller_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe Api::PushController, type: :controller do + describe 'POST #update' do + context 'with hub.mode=subscribe' do + pending + end + + context 'with hub.mode=unsubscribe' do + pending + end + end +end diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb index 6897caeeb..3d3a973d2 100644 --- a/spec/controllers/api/salmon_controller_spec.rb +++ b/spec/controllers/api/salmon_controller_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Api::SalmonController, type: :controller do let(:account) { Fabricate(:user, account: Fabricate(:account, username: 'catsrgr8')).account } before do + stub_request(:post, "https://pubsubhubbub.superfeedr.com/").to_return(:status => 200, :body => "", :headers => {}) stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:gargron@quitter.no").to_return(request_fixture('webfinger.txt')) stub_request(:get, "https://quitter.no/api/statuses/user_timeline/7477.atom").to_return(request_fixture('feed.txt')) diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb index 2af6cb725..44841176a 100644 --- a/spec/controllers/api/subscriptions_controller_spec.rb +++ b/spec/controllers/api/subscriptions_controller_spec.rb @@ -23,6 +23,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do let(:feed) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) } before do + stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {}) stub_request(:get, "https://quitter.no/avatar/7477-300-20160211190340.png").to_return(request_fixture('avatar.txt')) stub_request(:head, "https://quitter.no/notice/1269244").to_return(status: 404) stub_request(:head, "https://quitter.no/notice/1265331").to_return(status: 404) @@ -37,7 +38,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do stub_request(:head, "https://social.umeahackerspace.se/user/2").to_return(status: 404) stub_request(:head, "https://gs.kawa-kun.com/user/2").to_return(status: 404) stub_request(:head, "https://mastodon.social/users/Gargron").to_return(status: 404) - + request.env['HTTP_X_HUB_SIGNATURE'] = "sha1=#{OpenSSL::HMAC.hexdigest('sha1', 'abc', feed)}" request.env['RAW_POST_DATA'] = feed diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 98eea28ce..e4532305b 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -46,20 +46,6 @@ RSpec.describe Api::V1::AccountsController, type: :controller do end end - describe 'GET #suggestions' do - it 'returns http success' do - get :suggestions - expect(response).to have_http_status(:success) - end - end - - describe 'GET #common_followers' do - it 'returns http success' do - get :common_followers, params: { id: user.account.id } - expect(response).to have_http_status(:success) - end - end - describe 'POST #follow' do let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } diff --git a/spec/fabricators/subscription_fabricator.rb b/spec/fabricators/subscription_fabricator.rb new file mode 100644 index 000000000..0c8290494 --- /dev/null +++ b/spec/fabricators/subscription_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:subscription) do + callback_url "http://example.com/callback" + secret "foobar" + expires_at "2016-11-28 11:30:07" + confirmed false +end diff --git a/spec/helpers/admin/pubsubhubbub_helper_spec.rb b/spec/helpers/admin/pubsubhubbub_helper_spec.rb new file mode 100644 index 000000000..6603e6dc0 --- /dev/null +++ b/spec/helpers/admin/pubsubhubbub_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Admin::PubsubhubbubHelper. For example: +# +# describe Admin::PubsubhubbubHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Admin::PubsubhubbubHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb new file mode 100644 index 000000000..d40bf0b44 --- /dev/null +++ b/spec/models/subscription_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Subscription, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb index e4e5858ea..5e57d823b 100644 --- a/spec/services/process_feed_service_spec.rb +++ b/spec/services/process_feed_service_spec.rb @@ -7,6 +7,7 @@ RSpec.describe ProcessFeedService do subject { ProcessFeedService.new } before do + stub_request(:post, "https://pubsubhubbub.superfeedr.com/").to_return(:status => 200, :body => "", :headers => {}) stub_request(:get, "http://kickass.zone/system/accounts/avatars/000/000/001/large/eris.png").to_return(request_fixture('avatar.txt')) stub_request(:get, "http://kickass.zone/system/media_attachments/files/000/000/002/original/morpheus_linux.jpg?1476059910").to_return(request_fixture('attachment1.txt')) stub_request(:get, "http://kickass.zone/system/media_attachments/files/000/000/003/original/gizmo.jpg?1476060065").to_return(request_fixture('attachment2.txt')) diff --git a/spec/services/update_remote_profile_service_spec.rb b/spec/services/update_remote_profile_service_spec.rb index 1ffcfbfac..c3d76c653 100644 --- a/spec/services/update_remote_profile_service_spec.rb +++ b/spec/services/update_remote_profile_service_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe UpdateRemoteProfileService do - let(:xml) { Nokogiri::XML(File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom'))).at_xpath('//xmlns:author') } + let(:xml) { Nokogiri::XML(File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom'))).at_xpath('//xmlns:feed') } subject { UpdateRemoteProfileService.new } @@ -13,7 +13,7 @@ RSpec.describe UpdateRemoteProfileService do let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com') } before do - subject.(xml, remote_account) + subject.call(xml, remote_account) end it 'downloads new avatar' do @@ -34,10 +34,10 @@ RSpec.describe UpdateRemoteProfileService do end context 'with unchanged details' do - let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com',display_name: 'DIGITAL CAT', note: 'Software engineer, free time musician and DIGITAL SPORTS enthusiast. Likes cats. Warning: May contain memes', avatar_remote_url: 'https://quitter.no/avatar/7477-300-20160211190340.png') } + let(:remote_account) { Fabricate(:account, username: 'bob', domain: 'example.com', display_name: 'DIGITAL CAT', note: 'Software engineer, free time musician and DIGITAL SPORTS enthusiast. Likes cats. Warning: May contain memes', avatar_remote_url: 'https://quitter.no/avatar/7477-300-20160211190340.png') } before do - subject.(xml, remote_account) + subject.call(xml, remote_account) end it 'does not re-download avatar' do diff --git a/yarn.lock b/yarn.lock index afafe3bc5..0a41f8b90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1335,7 +1335,7 @@ clap@^1.0.9: dependencies: chalk "^1.1.3" -classnames@^2.1.2, classnames@^2.2.3: +classnames@^2.1.2, classnames@^2.2.3, classnames@~2.2: version "2.2.5" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" @@ -3841,7 +3841,7 @@ react-addons-perf@^15.3.2: version "15.3.2" resolved "https://registry.yarnpkg.com/react-addons-perf/-/react-addons-perf-15.3.2.tgz#bbdbebe8649f936f9636a5750ac145bf5c620213" -react-addons-pure-render-mixin@^15.3.1: +react-addons-pure-render-mixin@>=0.14.0, react-addons-pure-render-mixin@^15.3.1: version "15.3.2" resolved "https://registry.yarnpkg.com/react-addons-pure-render-mixin/-/react-addons-pure-render-mixin-15.3.2.tgz#c5f54764667ead26e6cdf7178b6c8dbbd8463ec2" @@ -4022,6 +4022,13 @@ react-themeable@^1.1.0: dependencies: object-assign "^3.0.0" +react-toggle@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-toggle/-/react-toggle-2.1.1.tgz#80600a64417a1acc8aaa4c1477f7fbdb88b988fb" + dependencies: + classnames "~2.2" + react-addons-pure-render-mixin ">=0.14.0" + react@^15.3.2: version "15.3.2" resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e" |