diff options
84 files changed, 911 insertions, 386 deletions
diff --git a/.env.production.sample b/.env.production.sample index 1a96775de..ef0af9d5c 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -43,5 +43,5 @@ SMTP_FROM_ADDRESS=notifications@example.com # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front # S3_CLOUDFRONT_HOST= -# Optional Firebase Cloud Messaging API key -FCM_API_KEY= +# Streaming API integration +# STREAMING_API_BASE_URL= diff --git a/.travis.yml b/.travis.yml index fe4549edd..b1b0c2bcd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ env: - LOCAL_DOMAIN=cb6e6126.ngrok.io - LOCAL_HTTPS=true - RAILS_ENV=test - + - CXX=g++-4.8 addons: postgresql: 9.4 @@ -23,6 +23,10 @@ services: bundler_args: --without development production --retry=3 --jobs=3 +before_install: + - sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + - sudo apt-get -qq update + - sudo apt-get -qq install g++-4.8 install: - nvm install $TRAVIS_NODE_VERSION - npm install -g npm@3 diff --git a/Gemfile b/Gemfile index 6fd86ec48..423560bb6 100644 --- a/Gemfile +++ b/Gemfile @@ -50,7 +50,6 @@ gem 'pg_search' gem 'simple-navigation' gem 'statsd-instrument' gem 'ruby-oembed', require: 'oembed' -gem 'fcm' gem 'react-rails' gem 'browserify-rails' diff --git a/Gemfile.lock b/Gemfile.lock index dd3105b38..4f54a621c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -147,9 +147,6 @@ GEM execjs (2.7.0) fabrication (2.15.2) fast_blank (1.0.0) - fcm (0.0.2) - httparty - json font-awesome-rails (4.6.3.1) railties (>= 3.2, < 5.1) fuubar (2.1.1) @@ -183,8 +180,6 @@ GEM domain_name (~> 0.5) http-form_data (1.0.1) http_parser.rb (0.6.0) - httparty (0.14.0) - multi_xml (>= 0.5.2) httplog (0.3.2) colorize i18n (0.7.0) @@ -232,7 +227,6 @@ GEM mini_portile2 (2.1.0) minitest (5.10.1) multi_json (1.12.1) - multi_xml (0.6.0) net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (4.0.1) @@ -470,7 +464,6 @@ DEPENDENCIES dotenv-rails fabrication fast_blank - fcm font-awesome-rails fuubar goldfinger diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index c442ded61..e2fffd932 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -13,4 +13,3 @@ //= require jquery //= require jquery_ujs //= require components -//= require cable diff --git a/app/assets/javascripts/cable.js b/app/assets/javascripts/cable.js deleted file mode 100644 index 03258761c..000000000 --- a/app/assets/javascripts/cable.js +++ /dev/null @@ -1,12 +0,0 @@ -// Action Cable provides the framework to deal with WebSockets in Rails. -// You can generate new channels where WebSocket features live using the rails generate channel command. -// -//= require action_cable -//= require_self - -(function() { - this.App || (this.App = {}); - - App.cable = ActionCable.createConsumer(); - -}).call(this); diff --git a/app/assets/javascripts/components/actions/blocks.jsx b/app/assets/javascripts/components/actions/blocks.jsx new file mode 100644 index 000000000..79e316497 --- /dev/null +++ b/app/assets/javascripts/components/actions/blocks.jsx @@ -0,0 +1,82 @@ +import api, { getLinks } from '../api' +import { fetchRelationships } from './accounts'; + +export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; +export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; +export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; + +export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; +export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; +export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; + +export function fetchBlocks() { + return (dispatch, getState) => { + dispatch(fetchBlocksRequest()); + + api(getState).get('/api/v1/blocks').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchBlocksFail(error))); + }; +}; + +export function fetchBlocksRequest() { + return { + type: BLOCKS_FETCH_REQUEST + }; +}; + +export function fetchBlocksSuccess(accounts, next) { + return { + type: BLOCKS_FETCH_SUCCESS, + accounts, + next + }; +}; + +export function fetchBlocksFail(error) { + return { + type: BLOCKS_FETCH_FAIL, + error + }; +}; + +export function expandBlocks() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'blocks', 'next']); + + if (url === null) { + return; + } + + dispatch(expandBlocksRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandBlocksFail(error))); + }; +}; + +export function expandBlocksRequest() { + return { + type: BLOCKS_EXPAND_REQUEST + }; +}; + +export function expandBlocksSuccess(accounts, next) { + return { + type: BLOCKS_EXPAND_SUCCESS, + accounts, + next + }; +}; + +export function expandBlocksFail(error) { + return { + type: BLOCKS_EXPAND_FAIL, + error + }; +}; diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index e11d1e537..f87518751 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -84,7 +84,7 @@ export function submitCompose() { // To make the app more responsive, immediately get the status into the columns dispatch(updateTimeline('home', { ...response.data })); - if (response.data.in_reply_to_id === null && !getState().getIn(['compose', 'private']) && !getState().getIn(['compose', 'unlisted'])) { + if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { dispatch(updateTimeline('public', { ...response.data })); } }).catch(function (error) { diff --git a/app/assets/javascripts/components/actions/modal.jsx b/app/assets/javascripts/components/actions/modal.jsx index 89dbc7947..d19218c48 100644 --- a/app/assets/javascripts/components/actions/modal.jsx +++ b/app/assets/javascripts/components/actions/modal.jsx @@ -1,10 +1,14 @@ export const MEDIA_OPEN = 'MEDIA_OPEN'; export const MODAL_CLOSE = 'MODAL_CLOSE'; -export function openMedia(url) { +export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE'; +export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE'; + +export function openMedia(media, index) { return { type: MEDIA_OPEN, - url: url + media, + index }; }; @@ -13,3 +17,15 @@ export function closeModal() { type: MODAL_CLOSE }; }; + +export function decreaseIndexInModal() { + return { + type: MODAL_INDEX_DECREASE + }; +}; + +export function increaseIndexInModal() { + return { + type: MODAL_INDEX_INCREASE + }; +}; diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index 4caf9c75b..df82e73fc 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -14,6 +14,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; + const fetchRelatedRelationships = (dispatch, notifications) => { const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); @@ -139,3 +141,13 @@ export function expandNotificationsFail(error) { error }; }; + +export function clearNotifications() { + return (dispatch, getState) => { + dispatch({ + type: NOTIFICATIONS_CLEAR + }); + + api(getState).post('/api/v1/notifications/clear'); + }; +}; diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx index 108401b2f..13ffab49b 100644 --- a/app/assets/javascripts/components/components/account.jsx +++ b/app/assets/javascripts/components/components/account.jsx @@ -51,7 +51,7 @@ const Account = React.createClass({ getDefaultProps () { return { - withNote: true + withNote: false }; }, diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx index 19c52550a..fb70d5772 100644 --- a/app/assets/javascripts/components/components/button.jsx +++ b/app/assets/javascripts/components/components/button.jsx @@ -3,12 +3,13 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; const Button = React.createClass({ propTypes: { - text: React.PropTypes.string, + text: React.PropTypes.node, onClick: React.PropTypes.func, disabled: React.PropTypes.bool, block: React.PropTypes.bool, secondary: React.PropTypes.bool, size: React.PropTypes.number, + children: React.PropTypes.node }, getDefaultProps () { @@ -38,7 +39,6 @@ const Button = React.createClass({ fontSize: '14px', fontWeight: '500', letterSpacing: '0', - textTransform: 'uppercase', padding: `0 ${this.props.size / 2.25}px`, height: `${this.props.size}px`, cursor: 'pointer', diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx index 203dc5e0c..90c561bce 100644 --- a/app/assets/javascripts/components/components/column_collapsable.jsx +++ b/app/assets/javascripts/components/components/column_collapsable.jsx @@ -47,7 +47,7 @@ const ColumnCollapsable = React.createClass({ <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}> {({ opacity, height }) => - <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}> + <div style={{ overflow: height === fullHeight ? 'auto' : 'hidden', height: `${height}px`, opacity: opacity / 100, maxHeight: '70vh' }}> {children} </div> } diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx index 1e3a88955..f04ca47ba 100644 --- a/app/assets/javascripts/components/components/lightbox.jsx +++ b/app/assets/javascripts/components/components/lightbox.jsx @@ -44,7 +44,7 @@ const Lightbox = React.createClass({ componentDidMount () { this._listener = e => { - if (e.key === 'Escape') { + if (this.props.isVisible && e.key === 'Escape') { this.props.onCloseClicked(); } }; @@ -56,14 +56,18 @@ const Lightbox = React.createClass({ window.removeEventListener('keyup', this._listener); }, + stopPropagation (e) { + e.stopPropagation(); + }, + render () { const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props; return ( <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}> {({ backgroundOpacity, opacity, y }) => - <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex'}} onClick={onOverlayClicked}> - <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}> + <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}> + <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}> <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} /> {children} </div> diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx index 7e92abe2d..a13448d0b 100644 --- a/app/assets/javascripts/components/components/media_gallery.jsx +++ b/app/assets/javascripts/components/components/media_gallery.jsx @@ -57,15 +57,16 @@ const MediaGallery = React.createClass({ sensitive: React.PropTypes.bool, media: ImmutablePropTypes.list.isRequired, height: React.PropTypes.number.isRequired, - onOpenMedia: React.PropTypes.func.isRequired + onOpenMedia: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired }, mixins: [PureRenderMixin], - handleClick (url, e) { + handleClick (index, e) { if (e.button === 0) { e.preventDefault(); - this.props.onOpenMedia(url); + this.props.onOpenMedia(this.props.media, index); } e.stopPropagation(); @@ -151,12 +152,12 @@ const MediaGallery = React.createClass({ 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('remote_url') ? attachment.get('remote_url') : 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' }} /> + <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} 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={{ ...outerStyle, height: `${this.props.height}px` }}> <div style={spoilerButtonStyle} > diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx index 44346fabc..9263a76f5 100644 --- a/app/assets/javascripts/components/components/status_content.jsx +++ b/app/assets/javascripts/components/components/status_content.jsx @@ -3,6 +3,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; import emojify from '../emoji'; import { FormattedMessage } from 'react-intl'; +import Permalink from './permalink'; const spoilerStyle = { display: 'inline-block', @@ -41,11 +42,14 @@ const StatusContent = React.createClass({ for (var i = 0; i < links.length; ++i) { let link = links[i]; let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); + let media = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || link.href === item.get('remote_url')); if (mention) { link.addEventListener('click', this.onMentionClick.bind(this, mention), false); } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else if (media) { + link.innerHTML = '<i class="fa fa-fw fa-photo"></i>'; } else { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); @@ -100,14 +104,28 @@ const StatusContent = React.createClass({ const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + <Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'> + @<span>{item.get('username')}</span> + </Permalink> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []) + const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + return ( <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> - <p style={{ marginBottom: hidden ? '0px' : '' }} > - <span dangerouslySetInnerHTML={spoilerContent} /> <a className='status__content__spoiler-link' style={spoilerStyle} onClick={this.handleSpoilerClick}>{toggleText}</a> + <p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} > + <span dangerouslySetInnerHTML={spoilerContent} /> <a className='status__content__spoiler-link' style={spoilerStyle} onClick={this.handleSpoilerClick}>{toggleText}</a> </p> + {mentionsPlaceholder} + <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} /> </div> ); diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 5fd43fb2b..3b36ce3ef 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -33,6 +33,7 @@ import Notifications from '../features/notifications'; import FollowRequests from '../features/follow_requests'; import GenericNotFound from '../features/generic_not_found'; import FavouritedStatuses from '../features/favourited_statuses'; +import Blocks from '../features/blocks'; import { IntlProvider, addLocaleData } from 'react-intl'; import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; @@ -43,6 +44,7 @@ import hu from 'react-intl/locale-data/hu'; import uk from 'react-intl/locale-data/uk'; import getMessagesForLocale from '../locales'; import { hydrateStore } from '../actions/store'; +import createStream from '../stream'; const store = configureStore(); @@ -60,28 +62,27 @@ const Mastodon = React.createClass({ locale: React.PropTypes.string.isRequired }, - componentWillMount() { - const { locale } = this.props; - - if (typeof App !== 'undefined') { - this.subscription = App.cable.subscriptions.create('TimelineChannel', { - - received (data) { - switch(data.event) { - case 'update': - store.dispatch(updateTimeline('home', JSON.parse(data.payload))); - break; - case 'delete': - store.dispatch(deleteFromTimelines(data.payload)); - break; - case 'notification': - store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale)); - break; - } + componentDidMount() { + const { locale } = this.props; + const accessToken = store.getState().getIn(['meta', 'access_token']); + + this.subscription = createStream(accessToken, 'user', { + + received (data) { + switch(data.event) { + case 'update': + store.dispatch(updateTimeline('home', JSON.parse(data.payload))); + break; + case 'delete': + store.dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale)); + break; } + } - }); - } + }); // Desktop notifications if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { @@ -91,7 +92,8 @@ const Mastodon = React.createClass({ componentWillUnmount () { if (typeof this.subscription !== 'undefined') { - this.subscription.unsubscribe(); + this.subscription.close(); + this.subscription = null; } }, @@ -123,6 +125,8 @@ const Mastodon = React.createClass({ <Route path='accounts/:accountId/following' component={Following} /> <Route path='follow_requests' component={FollowRequests} /> + <Route path='blocks' component={Blocks} /> + <Route path='*' component={GenericNotFound} /> </Route> </Router> diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index 1704a8cc2..f5fb09d52 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -91,8 +91,8 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(mentionCompose(account, router)); }, - onOpenMedia (url) { - dispatch(openMedia(url)); + onOpenMedia (media, index) { + dispatch(openMedia(media, index)); }, onBlock (account) { diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index dead11265..30e0449c5 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -44,7 +44,7 @@ const Header = React.createClass({ <IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} /> </div> ); - } else { + } else if (!account.getIn(['relationship', 'blocking'])) { 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} /> diff --git a/app/assets/javascripts/components/features/blocks/index.jsx b/app/assets/javascripts/components/features/blocks/index.jsx new file mode 100644 index 000000000..e941b27f7 --- /dev/null +++ b/app/assets/javascripts/components/features/blocks/index.jsx @@ -0,0 +1,68 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import AccountContainer from '../../containers/account_container'; +import { fetchBlocks, expandBlocks } from '../../actions/blocks'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + heading: { id: 'column.blocks', defaultMessage: 'Blocked users' } +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'blocks', 'items']) +}); + +const Blocks = React.createClass({ + propTypes: { + params: React.PropTypes.object.isRequired, + dispatch: React.PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: React.PropTypes.object.isRequired + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + this.props.dispatch(fetchBlocks()); + }, + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandBlocks()); + } + }, + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='users' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='blocks'> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } +}); + +export default connect(mapStateToProps)(injectIntl(Blocks)); 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 5073c9d9e..48939054d 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -117,9 +117,10 @@ const ComposeForm = React.createClass({ }, render () { - const { intl } = this.props; - let replyArea = ''; - const disabled = this.props.is_submitting || this.props.is_uploading; + const { intl } = this.props; + let replyArea = ''; + let publishText = ''; + const disabled = this.props.is_submitting || this.props.is_uploading; if (this.props.in_reply_to) { replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />; @@ -127,6 +128,12 @@ const ComposeForm = React.createClass({ let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me); + if (this.props.private) { + publishText = <span><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; + } else { + publishText = intl.formatMessage(messages.publish) + (!this.props.unlisted ? '!' : ''); + } + return ( <div style={{ padding: '10px' }}> <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}> @@ -154,19 +161,19 @@ const ComposeForm = React.createClass({ /> <div style={{ marginTop: '10px', overflow: 'hidden' }}> - <div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div> + <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div> <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div> <UploadButtonContainer style={{ paddingTop: '4px' }} /> </div> <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', marginTop: '10px', borderTop: '1px solid #282c37', paddingTop: '10px' }}> - <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} /> - <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span> + <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} /> + <span style={{ display: 'inline-block', verticalAlign: 'top', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span> </label> - <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}> - <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} /> - <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span> + <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', borderTop: '1px solid #282c37', paddingTop: '10px' }}> + <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} /> + <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span> </label> <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}> 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 8ccfce059..c027875cd 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 @@ -26,14 +26,14 @@ const makeMapStateToProps = () => { sensitive: state.getIn(['compose', 'sensitive']), spoiler: state.getIn(['compose', 'spoiler']), spoiler_text: state.getIn(['compose', 'spoiler_text']), - unlisted: state.getIn(['compose', 'unlisted']), + unlisted: state.getIn(['compose', 'unlisted'], ), private: state.getIn(['compose', 'private']), fileDropDate: state.getIn(['compose', 'fileDropDate']), 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'])), media_count: state.getIn(['compose', 'media_attachments']).size, - me: state.getIn(['compose', 'me']) + me: state.getIn(['compose', 'me']), }; }; diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index 42e0a9e24..a0bf3a694 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -11,7 +11,9 @@ const messages = defineMessages({ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' }, - favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' } + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, + info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' } }); const mapStateToProps = state => ({ @@ -32,6 +34,8 @@ const GettingStarted = ({ intl, me }) => { <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> {followRequests} + <ColumnLink icon='users' text={intl.formatMessage(messages.blocks)} to='/blocks' /> + <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> </div> diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx index 7548e6d56..4a0e7684d 100644 --- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx +++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx @@ -8,45 +8,49 @@ import { deleteFromTimelines } from '../../actions/timelines'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const mapStateToProps = state => ({ + accessToken: state.getIn(['meta', 'access_token']) +}); const HashtagTimeline = React.createClass({ propTypes: { params: React.PropTypes.object.isRequired, - dispatch: React.PropTypes.func.isRequired + dispatch: React.PropTypes.func.isRequired, + accessToken: React.PropTypes.string.isRequired }, mixins: [PureRenderMixin], _subscribe (dispatch, id) { - if (typeof App !== 'undefined') { - this.subscription = App.cable.subscriptions.create({ - channel: 'HashtagChannel', - tag: id - }, { - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('tag', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } + const { accessToken } = this.props; + + this.subscription = createStream(accessToken, `hashtag&tag=${id}`, { + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('tag', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; } + } - }); - } + }); }, _unsubscribe () { if (typeof this.subscription !== 'undefined') { - this.subscription.unsubscribe(); + this.subscription.close(); + this.subscription = null; } }, - componentWillMount () { + componentDidMount () { const { dispatch } = this.props; const { id } = this.props.params; @@ -79,4 +83,4 @@ const HashtagTimeline = React.createClass({ }); -export default connect()(HashtagTimeline); +export default connect(mapStateToProps)(HashtagTimeline); diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx new file mode 100644 index 000000000..d20a4d170 --- /dev/null +++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx @@ -0,0 +1,21 @@ +const iconStyle = { + fontSize: '16px', + padding: '15px', + position: 'absolute', + right: '48px', + top: '0', + cursor: 'pointer', + background: '#2f3441' +}; + +const ClearColumnButton = ({ onClick }) => ( + <div className='column-icon' style={iconStyle} onClick={onClick}> + <i className='fa fa-trash' /> + </div> +); + +ClearColumnButton.propTypes = { + onClick: React.PropTypes.func.isRequired +}; + +export default ClearColumnButton; diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx index d3300acd5..6d10768de 100644 --- a/app/assets/javascripts/components/features/notifications/index.jsx +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../ui/components/column'; -import { expandNotifications } from '../../actions/notifications'; +import { expandNotifications, clearNotifications } from '../../actions/notifications'; import NotificationContainer from './containers/notification_container'; import { ScrollContainer } from 'react-router-scroll'; import { defineMessages, injectIntl } from 'react-intl'; @@ -10,6 +10,7 @@ import ColumnSettingsContainer from './containers/column_settings_container'; import { createSelector } from 'reselect'; import Immutable from 'immutable'; import LoadMore from '../../components/load_more'; +import ClearColumnButton from './components/clear_column_button'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' } @@ -64,6 +65,10 @@ const Notifications = React.createClass({ this.props.dispatch(expandNotifications()); }, + handleClear () { + this.props.dispatch(clearNotifications()); + }, + setRef (c) { this.node = c; }, @@ -90,6 +95,7 @@ const Notifications = React.createClass({ return ( <Column icon='bell' heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> + <ClearColumnButton onClick={this.handleClear} /> <ScrollContainer scrollKey='notifications'> {scrollableArea} </ScrollContainer> @@ -99,6 +105,7 @@ const Notifications = React.createClass({ return ( <Column icon='bell' heading={intl.formatMessage(messages.title)}> <ColumnSettingsContainer /> + <ClearColumnButton onClick={this.handleClear} /> {scrollableArea} </Column> ); diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx index 42970061c..36d68dbbb 100644 --- a/app/assets/javascripts/components/features/public_timeline/index.jsx +++ b/app/assets/javascripts/components/features/public_timeline/index.jsx @@ -9,46 +9,51 @@ import { } from '../../actions/timelines'; import { defineMessages, injectIntl } from 'react-intl'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; const messages = defineMessages({ title: { id: 'column.public', defaultMessage: 'Public' } }); +const mapStateToProps = state => ({ + accessToken: state.getIn(['meta', 'access_token']) +}); + const PublicTimeline = React.createClass({ propTypes: { dispatch: React.PropTypes.func.isRequired, - intl: React.PropTypes.object.isRequired + intl: React.PropTypes.object.isRequired, + accessToken: React.PropTypes.string.isRequired }, mixins: [PureRenderMixin], - componentWillMount () { - const { dispatch } = this.props; + componentDidMount () { + const { dispatch, accessToken } = this.props; dispatch(refreshTimeline('public')); - if (typeof App !== 'undefined') { - this.subscription = App.cable.subscriptions.create('PublicChannel', { + this.subscription = createStream(accessToken, 'public', { - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('public', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('public', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; } + } - }); - } + }); }, componentWillUnmount () { if (typeof this.subscription !== 'undefined') { - this.subscription.unsubscribe(); + this.subscription.close(); + this.subscription = null; } }, @@ -65,4 +70,4 @@ const PublicTimeline = React.createClass({ }); -export default connect()(injectIntl(PublicTimeline)); +export default connect(mapStateToProps)(injectIntl(PublicTimeline)); diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 993c649d2..894fa3176 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -84,8 +84,8 @@ const Status = React.createClass({ this.props.dispatch(mentionCompose(account, router)); }, - handleOpenMedia (url) { - this.props.dispatch(openMedia(url)); + handleOpenMedia (media, index) { + this.props.dispatch(openMedia(media, index)); }, renderChildren (list) { diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx index c382e108d..2eafe5a8f 100644 --- a/app/assets/javascripts/components/features/ui/components/column.jsx +++ b/app/assets/javascripts/components/features/ui/components/column.jsx @@ -1,4 +1,4 @@ -import ColumnHeader from './column_header'; +import ColumnHeader from './column_header'; import PureRenderMixin from 'react-addons-pure-render-mixin'; const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b; @@ -58,16 +58,18 @@ const Column = React.createClass({ }, render () { + const { heading, icon, children } = this.props; + let header = ''; - if (this.props.heading) { - header = <ColumnHeader icon={this.props.icon} type={this.props.heading} onClick={this.handleHeaderClick} />; + if (heading) { + header = <ColumnHeader icon={icon} type={heading} onClick={this.handleHeaderClick} />; } return ( <div className='column' style={style} onWheel={this.handleWheel}> {header} - {this.props.children} + {children} </div> ); } diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx index 53d162462..334e5c199 100644 --- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx @@ -1,12 +1,18 @@ import { connect } from 'react-redux'; -import { closeModal } from '../../../actions/modal'; +import { + closeModal, + decreaseIndexInModal, + increaseIndexInModal +} from '../../../actions/modal'; import Lightbox from '../../../components/lightbox'; import ImageLoader from 'react-imageloader'; import LoadingIndicator from '../../../components/loading_indicator'; import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; const mapStateToProps = state => ({ - url: state.getIn(['modal', 'url']), + media: state.getIn(['modal', 'media']), + index: state.getIn(['modal', 'index']), isVisible: state.getIn(['modal', 'open']) }); @@ -17,6 +23,14 @@ const mapDispatchToProps = dispatch => ({ onOverlayClicked () { dispatch(closeModal()); + }, + + onNextClicked () { + dispatch(increaseIndexInModal()); + }, + + onPrevClicked () { + dispatch(decreaseIndexInModal()); } }); @@ -38,27 +52,115 @@ const preloader = () => ( </div> ); +const leftNavStyle = { + position: 'absolute', + background: 'rgba(0, 0, 0, 0.5)', + padding: '30px 15px', + cursor: 'pointer', + color: '#fff', + fontSize: '24px', + top: '0', + left: '-61px', + boxSizing: 'border-box', + height: '100%', + display: 'flex', + alignItems: 'center' +}; + +const rightNavStyle = { + position: 'absolute', + background: 'rgba(0, 0, 0, 0.5)', + padding: '30px 15px', + cursor: 'pointer', + color: '#fff', + fontSize: '24px', + top: '0', + right: '-61px', + boxSizing: 'border-box', + height: '100%', + display: 'flex', + alignItems: 'center' +}; + const Modal = React.createClass({ propTypes: { - url: React.PropTypes.string, + media: ImmutablePropTypes.list, + index: React.PropTypes.number.isRequired, isVisible: React.PropTypes.bool, onCloseClicked: React.PropTypes.func, - onOverlayClicked: React.PropTypes.func + onOverlayClicked: React.PropTypes.func, + onNextClicked: React.PropTypes.func, + onPrevClicked: React.PropTypes.func }, mixins: [PureRenderMixin], + handleNextClick () { + this.props.onNextClicked(); + }, + + handlePrevClick () { + this.props.onPrevClicked(); + }, + + componentDidMount () { + this._listener = e => { + if (!this.props.isVisible) { + return; + } + + switch(e.key) { + case 'ArrowLeft': + this.props.onPrevClicked(); + break; + case 'ArrowRight': + this.props.onNextClicked(); + break; + } + }; + + window.addEventListener('keyup', this._listener); + }, + + componentWillUnmount () { + window.removeEventListener('keyup', this._listener); + }, + render () { - const { url, ...other } = this.props; + const { media, index, ...other } = this.props; + + if (!media) { + return null; + } + + const url = media.get(index).get('url'); + const hasLeft = index > 0; + const hasRight = index + 1 < media.size; + + let leftNav, rightNav; + + leftNav = rightNav = ''; + + if (hasLeft) { + leftNav = <div style={leftNavStyle} onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; + } + + if (hasRight) { + rightNav = <div style={rightNavStyle} onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; + } return ( <Lightbox {...other}> + {leftNav} + <ImageLoader src={url} preloader={preloader} imgProps={{ style: imageStyle }} /> + + {rightNav} </Lightbox> ); } diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 8af7b0c3c..100989d22 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -7,8 +7,9 @@ import { createSelector } from 'reselect'; const getStatusIds = createSelector([ (state, { type }) => state.getIn(['settings', type], Immutable.Map()), (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()), - (state) => state.get('statuses') -], (columnSettings, statusIds, statuses) => statusIds.filter(id => { + (state) => state.get('statuses'), + (state) => state.getIn(['meta', 'me']) +], (columnSettings, statusIds, statuses, me) => statusIds.filter(id => { const statusForId = statuses.get(id); let showStatus = true; @@ -17,7 +18,7 @@ const getStatusIds = createSelector([ } if (columnSettings.getIn(['shows', 'reply']) === false) { - showStatus = showStatus && statusForId.get('in_reply_to_id') === null; + showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); } if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) { diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 409dfd663..f3938cee1 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -7,9 +7,14 @@ import { ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS, FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, ACCOUNT_FOLLOW_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS } from '../actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; import { REBLOG_SUCCESS, @@ -87,6 +92,9 @@ export default function accounts(state = initialState, action) { case COMPOSE_SUGGESTIONS_READY: case SEARCH_SUGGESTIONS_READY: case FOLLOW_REQUESTS_FETCH_SUCCESS: + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + case BLOCKS_FETCH_SUCCESS: + case BLOCKS_EXPAND_SUCCESS: return normalizeAccounts(state, action.accounts); case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index d3a84842f..1b903ed44 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -43,6 +43,7 @@ const initialState = Immutable.Map({ suggestion_token: null, suggestions: Immutable.List(), me: null, + default_privacy: 'public', resetFileKey: Math.floor((Math.random() * 0x10000)) }); @@ -64,6 +65,8 @@ function clearAll(state) { map.set('spoiler_text', ''); map.set('is_submitting', false); map.set('in_reply_to', null); + map.set('unlisted', state.get('default_privacy') === 'unlisted'); + map.set('private', state.get('default_privacy') === 'private'); map.update('media_attachments', list => list.clear()); }); }; @@ -97,7 +100,7 @@ const insertSuggestion = (state, position, token, completion) => { export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - return state.merge(action.state.get('compose')); + return clearAll(state.merge(action.state.get('compose'))); case COMPOSE_MOUNT: return state.set('mounted', true); case COMPOSE_UNMOUNT: diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx index ac53ea210..07da65771 100644 --- a/app/assets/javascripts/components/reducers/modal.jsx +++ b/app/assets/javascripts/components/reducers/modal.jsx @@ -1,8 +1,14 @@ -import { MEDIA_OPEN, MODAL_CLOSE } from '../actions/modal'; -import Immutable from 'immutable'; +import { + MEDIA_OPEN, + MODAL_CLOSE, + MODAL_INDEX_DECREASE, + MODAL_INDEX_INCREASE +} from '../actions/modal'; +import Immutable from 'immutable'; const initialState = Immutable.Map({ - url: '', + media: null, + index: 0, open: false }); @@ -10,11 +16,16 @@ export default function modal(state = initialState, action) { switch(action.type) { case MEDIA_OPEN: return state.withMutations(map => { - map.set('url', action.url); + map.set('media', action.media); + map.set('index', action.index); map.set('open', true); }); case MODAL_CLOSE: return state.set('open', false); + case MODAL_INDEX_DECREASE: + return state.update('index', index => Math.max(index - 1, 0)); + case MODAL_INDEX_INCREASE: + return state.update('index', index => Math.min(index + 1, state.get('media').size - 1)); default: return state; } diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index 482093c33..4a7af8856 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -5,7 +5,8 @@ import { NOTIFICATIONS_REFRESH_REQUEST, NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_REFRESH_FAIL, - NOTIFICATIONS_EXPAND_FAIL + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_CLEAR } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import Immutable from 'immutable'; @@ -75,6 +76,8 @@ export default function notifications(state = initialState, action) { return appendNormalizedNotifications(state, action.notifications, action.next); case ACCOUNT_BLOCK_SUCCESS: return filterNotifications(state, action.relationship); + case NOTIFICATIONS_CLEAR: + return state.set('items', Immutable.List()).set('next', null); default: return state; } diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx index 72922f509..8c9a3d3aa 100644 --- a/app/assets/javascripts/components/reducers/user_lists.jsx +++ b/app/assets/javascripts/components/reducers/user_lists.jsx @@ -4,6 +4,7 @@ import { FOLLOWING_FETCH_SUCCESS, FOLLOWING_EXPAND_SUCCESS, FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, FOLLOW_REQUEST_AUTHORIZE_SUCCESS, FOLLOW_REQUEST_REJECT_SUCCESS } from '../actions/accounts'; @@ -11,6 +12,10 @@ import { REBLOGS_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS } from '../actions/interactions'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; import Immutable from 'immutable'; const initialState = Immutable.Map({ @@ -18,7 +23,8 @@ const initialState = Immutable.Map({ following: Immutable.Map(), reblogged_by: Immutable.Map(), favourited_by: Immutable.Map(), - follow_requests: Immutable.Map() + follow_requests: Immutable.Map(), + blocks: Immutable.Map() }); const normalizeList = (state, type, id, accounts, next) => { @@ -50,9 +56,15 @@ export default function userLists(state = initialState, action) { return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); case FOLLOW_REQUESTS_FETCH_SUCCESS: return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.push(...action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: case FOLLOW_REQUEST_REJECT_SUCCESS: return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + case BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], list => list.push(...action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); default: return state; } diff --git a/app/assets/javascripts/components/stream.jsx b/app/assets/javascripts/components/stream.jsx new file mode 100644 index 000000000..0787399f6 --- /dev/null +++ b/app/assets/javascripts/components/stream.jsx @@ -0,0 +1,21 @@ +import WebSocketClient from 'websocket.js'; + +const createWebSocketURL = (url) => { + const a = document.createElement('a'); + + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace('http', 'ws'); + + return a.href; +}; + +export default function getStream(accessToken, stream, { connected, received, disconnected }) { + const ws = new WebSocketClient(`${createWebSocketURL(STREAMING_API_BASE_URL)}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`); + + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + ws.onclose = disconnected; + + return ws; +}; diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index ca0ec0cec..13df099b1 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -28,15 +28,15 @@ } &.button-secondary { - background-color: $color1; + // + } +} - &:hover { - background-color: $color1; - } +.column-icon { + color: $color3; - &:disabled { - background-color: $color3; - } + &:hover { + color: lighten($color3, 7%); } } @@ -125,6 +125,10 @@ &:hover { text-decoration: underline; + + .fa { + color: lighten($color1, 40%); + } } &.mention { @@ -136,6 +140,10 @@ } } } + + .fa { + color: lighten($color1, 30%); + } } .status__content__spoiler-link { diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss index de4157af8..cdf81c818 100644 --- a/app/assets/stylesheets/variables.scss +++ b/app/assets/stylesheets/variables.scss @@ -2,7 +2,7 @@ $color1: #282c37; // darkest $color2: #d9e1e8; // lightest $color3: #9baec8; // lighter $color4: #2b90d9; // vibrant -$color5: #fff; // white +$color5: #ffffff; // white $color6: #df405a; // error red $color7: #79bd9a; // succ green -$color8: #000; // black +$color8: #000000; // black diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index b9816e052..08aefc175 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -9,7 +9,7 @@ class Api::V1::BlocksController < ApiController def index results = Block.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) 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] } + @accounts = results.map { |f| accounts[f.target_account_id] }.compact set_account_counters_maps(@accounts) diff --git a/app/controllers/api/v1/devices_controller.rb b/app/controllers/api/v1/devices_controller.rb deleted file mode 100644 index c565e972b..000000000 --- a/app/controllers/api/v1/devices_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::DevicesController < ApiController - before_action -> { doorkeeper_authorize! :read } - before_action :require_user! - - respond_to :json - - def register - Device.where(account: current_account, registration_id: params[:registration_id]).first_or_create!(account: current_account, registration_id: params[:registration_id]) - render_empty - end - - def unregister - Device.where(account: current_account, registration_id: params[:registration_id]).delete_all - render_empty - end -end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 4b095a570..69cbdce5d 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -14,7 +14,12 @@ class Api::V1::StatusesController < ApiController end def context - @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account)) + ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account) + descendants_results = @status.descendants(current_account) + loaded_ancestors = cache_collection(ancestors_results, Status) + loaded_descendants = cache_collection(descendants_results, Status) + + @context = OpenStruct.new(ancestors: loaded_ancestors, descendants: loaded_descendants) statuses = [@status] + @context[:ancestors] + @context[:descendants] set_maps(statuses) diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb index 854ca13e6..a8cc2b288 100644 --- a/app/controllers/api/v1/timelines_controller.rb +++ b/app/controllers/api/v1/timelines_controller.rb @@ -23,7 +23,7 @@ class Api::V1::TimelinesController < ApiController end def public - @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + @statuses = Status.as_public_timeline(current_account, params[:local]).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) @@ -40,7 +40,7 @@ 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(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) + @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses) set_maps(@statuses) diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 5ad825675..b7479bf8c 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -21,7 +21,9 @@ class Settings::PreferencesController < ApplicationController must_be_following: user_params[:interactions][:must_be_following] == '1', } - if current_user.update(user_params.except(:notification_emails, :interactions)) + current_user.settings['default_privacy'] = user_params[:setting_default_privacy] + + if current_user.update(user_params.except(:notification_emails, :interactions, :setting_default_privacy)) redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg') else render action: :show @@ -31,6 +33,6 @@ class Settings::PreferencesController < ApplicationController private def user_params - params.require(:user).permit(:locale, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following]) + params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following]) end end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 5701b2efa..da284d80e 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -14,8 +14,8 @@ class StreamEntriesController < ApplicationController return gone if @stream_entry.activity.nil? if @stream_entry.activity_type == 'Status' - @ancestors = @stream_entry.activity.ancestors(current_account) - @descendants = @stream_entry.activity.descendants(current_account) + @ancestors = @stream_entry.activity.reply? ? cache_collection(@stream_entry.activity.ancestors(current_account), Status) : [] + @descendants = cache_collection(@stream_entry.activity.descendants(current_account), Status) end end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 028fc5218..7069026e3 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -30,6 +30,7 @@ class FeedManager end def broadcast(timeline_id, options = {}) + options[:queued_at] = (Time.now.to_f * 1000.0).to_i ActionCable.server.broadcast("timeline:#{timeline_id}", options) end diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index ff2a16f1b..044407a6c 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -68,8 +68,9 @@ class Formatter prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s text = url[prefix.length, 30] suffix = url[prefix.length + 30..-1] + cutoff = url[prefix.length..-1].length > 30 - "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"ellipsis\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>" + "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>" end def hashtag_html(match) diff --git a/app/models/device.rb b/app/models/device.rb deleted file mode 100644 index 2782a7f38..000000000 --- a/app/models/device.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class Device < ApplicationRecord - belongs_to :account - - validates :account, :registration_id, presence: true -end diff --git a/app/models/favourite.rb b/app/models/favourite.rb index 147105e48..3f3616dce 100644 --- a/app/models/favourite.rb +++ b/app/models/favourite.rb @@ -5,7 +5,7 @@ class Favourite < ApplicationRecord include Streamable belongs_to :account, inverse_of: :favourites - belongs_to :status, inverse_of: :favourites, touch: true + belongs_to :status, inverse_of: :favourites has_one :notification, as: :activity, dependent: :destroy diff --git a/app/models/status.rb b/app/models/status.rb index 63f5d5fa4..6ef0b2bdd 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -14,7 +14,7 @@ class Status < ApplicationRecord belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account' 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 + belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs has_many :favourites, inverse_of: :status, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy @@ -81,7 +81,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_includes.group_by(&:id) + statuses = Status.where(id: ids).group_by(&:id) results = ids.map { |id| statuses[id].first } results = results.reject { |status| filter_from_context?(status, account) } @@ -90,7 +90,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_includes.group_by(&:id) + statuses = Status.where(id: ids).group_by(&:id) results = ids.map { |id| statuses[id].first } results = results.reject { |status| filter_from_context?(status, account) } @@ -102,21 +102,25 @@ class Status < ApplicationRecord where(account: [account] + account.following) end - def as_public_timeline(account = nil) + def as_public_timeline(account = nil, local_only = false) query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') .where(visibility: :public) .where('(statuses.in_reply_to_id IS NULL OR statuses.in_reply_to_account_id = statuses.account_id)') .where('statuses.reblog_of_id IS NULL') + query = query.where('accounts.domain IS NULL') if local_only + account.nil? ? filter_timeline_default(query) : filter_timeline_default(filter_timeline(query, account)) end - def as_tag_timeline(tag, account = nil) + def as_tag_timeline(tag, account = nil, local_only = false) query = tag.statuses .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') .where(visibility: :public) .where('statuses.reblog_of_id IS NULL') + query = query.where('accounts.domain IS NULL') if local_only + account.nil? ? filter_timeline_default(query) : filter_timeline_default(filter_timeline(query, account)) end @@ -157,7 +161,7 @@ class Status < ApplicationRecord private def filter_timeline(query, account) - blocked = Block.where(account: account).pluck(:target_account_id) + blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) query = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty? query = query.where('accounts.silenced = TRUE') if account.silenced? query @@ -180,6 +184,6 @@ class Status < ApplicationRecord private def filter_from_context?(status, account) - account&.blocking?(status.account) || !status.permitted?(account) + account&.blocking?(status.account_id) || !status.permitted?(account) end end diff --git a/app/models/user.rb b/app/models/user.rb index b34144f2c..08aac2679 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,4 +21,8 @@ class User < ApplicationRecord def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later end + + def setting_default_privacy + settings.default_privacy || (account.locked? ? 'private' : 'public') + end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 13aad4632..71f6cbca1 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -34,13 +34,21 @@ class FanOutOnWriteService < BaseService def deliver_to_hashtags(status) Rails.logger.debug "Delivering status #{status.id} to hashtags" + + payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status) + status.tags.find_each do |tag| - FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)) + FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: payload) + FeedManager.instance.broadcast("hashtag:#{tag.name}:local", event: 'update', payload: payload) if status.account.local? end end def deliver_to_public(status) Rails.logger.debug "Delivering status #{status.id} to public timeline" - FeedManager.instance.broadcast(:public, event: 'update', payload: FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)) + + payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status) + + FeedManager.instance.broadcast(:public, event: 'update', payload: payload) + FeedManager.instance.broadcast('public:local', event: 'update', payload: payload) if status.account.local? end end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 87c16a621..9f34cb6ac 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -8,7 +8,7 @@ class FollowService < BaseService target_account = follow_remote_account_service.call(uri) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? - raise Mastodon::NotPermitted if target_account.blocking?(source_account) + raise Mastodon::NotPermitted if target_account.blocking?(source_account) || source_account.blocking?(target_account) if target_account.locked? request_follow(source_account, target_account) diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 0cc3cd618..942cd9d21 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -10,7 +10,6 @@ class NotifyService < BaseService create_notification send_email if email_enabled? - send_push_notification rescue ActiveRecord::RecordInvalid return end @@ -58,10 +57,6 @@ class NotifyService < BaseService NotificationMailer.send(@notification.type, @recipient, @notification).deliver_later end - def send_push_notification - PushNotificationWorker.perform_async(@notification.id) - end - def email_enabled? @recipient.user.settings.notification_emails[@notification.type] end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index e9a27f136..04de8a134 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -17,7 +17,7 @@ class SearchService < BaseService results = results.limit(limit).to_a results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match - if resolve && results.empty? && !domain.nil? + if resolve && !exact_match && !domain.nil? results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")] end diff --git a/app/services/send_push_notification_service.rb b/app/services/send_push_notification_service.rb deleted file mode 100644 index 526ae20cb..000000000 --- a/app/services/send_push_notification_service.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class SendPushNotificationService < BaseService - def call(notification) - return if ENV['FCM_API_KEY'].blank? - - devices = Device.where(account: notification.account).pluck(:registration_id) - fcm = FCM.new(ENV['FCM_API_KEY']) - - response = fcm.send(devices, data: { notification_id: notification.id }, collapse_key: :notifications, priority: :high) - handle_response(response) - end - - private - - def handle_response(response) - update_canonical_ids(response[:canonical_ids]) if response[:canonical_ids] - remove_bad_ids(response[:not_registered_ids]) if response[:not_registered_ids] - end - - def update_canonical_ids(ids) - ids.each { |pair| Device.find_by(registration_id: pair[:old]).update(registration_id: pair[:new]) } - end - - def remove_bad_ids(bad_ids) - Device.where(registration_id: bad_ids).delete_all unless bad_ids.empty? - end -end diff --git a/app/services/warm_cache_service.rb b/app/services/warm_cache_service.rb new file mode 100644 index 000000000..091a471ff --- /dev/null +++ b/app/services/warm_cache_service.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class WarmCacheService < BaseService + def call(cacheable) + full_item = cacheable.class.where(id: cacheable.id).with_includes.first + Rails.cache.write(cacheable.cache_key, full_item) + end +end diff --git a/app/views/api/v1/statuses/_media.rabl b/app/views/api/v1/statuses/_media.rabl index 5c6be1ce7..2f56c6d07 100644 --- a/app/views/api/v1/statuses/_media.rabl +++ b/app/views/api/v1/statuses/_media.rabl @@ -2,3 +2,4 @@ attributes :id, :remote_url, :type node(:url) { |media| full_asset_url(media.file.url(:original)) } node(:preview_url) { |media| full_asset_url(media.file.url(:small)) } +node(:text_url) { |media| media.local? ? medium_url(media) : nil } diff --git a/app/views/api/v1/statuses/_mention.rabl b/app/views/api/v1/statuses/_mention.rabl index 07b3d1f61..498cca275 100644 --- a/app/views/api/v1/statuses/_mention.rabl +++ b/app/views/api/v1/statuses/_mention.rabl @@ -1,3 +1,4 @@ -node(:url) { |mention| TagManager.instance.url_for(mention.account) } -node(:acct) { |mention| mention.account.acct } -node(:id) { |mention| mention.account_id } +node(:url) { |mention| TagManager.instance.url_for(mention.account) } +node(:acct) { |mention| mention.account.acct } +node(:id) { |mention| mention.account_id } +node(:username) { |mention| mention.account.username } diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl index 7309a78b8..059e0d13f 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, :sensitive, :spoiler_text, :visibility +attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility node(:uri) { |status| TagManager.instance.uri_for(status) } node(:content) { |status| Formatter.instance.format(status) } diff --git a/app/views/api/v1/statuses/show.rabl b/app/views/api/v1/statuses/show.rabl index 1b4651cdd..41e8983ef 100644 --- a/app/views/api/v1/statuses/show.rabl +++ b/app/views/api/v1/statuses/show.rabl @@ -2,12 +2,12 @@ object @status extends 'api/v1/statuses/_show' -node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? !!@favourites_map[status.id] : current_account.favourited?(status) } -node(:reblogged, if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map) ? !!@reblogs_map[status.id] : current_account.reblogged?(status) } +node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? @favourites_map[status.id] : current_account.favourited?(status) } +node(:reblogged, if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map) ? @reblogs_map[status.id] : current_account.reblogged?(status) } -child :reblog => :reblog do +child reblog: :reblog do extends 'api/v1/statuses/_show' - node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? !!@favourites_map[status.id] : current_account.favourited?(status) } - node(:reblogged, if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map) ? !!@reblogs_map[status.id] : current_account.reblogged?(status) } + node(:favourited, if: proc { !current_account.nil? }) { |status| defined?(@favourites_map) ? @favourites_map[status.id] : current_account.favourited?(status) } + node(:reblogged, if: proc { !current_account.nil? }) { |status| defined?(@reblogs_map) ? @reblogs_map[status.id] : current_account.reblogged?(status) } end diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 0147f4064..9e3b94463 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,5 +1,6 @@ - content_for :header_tags do :javascript + window.STREAMING_API_BASE_URL = '#{Rails.configuration.x.streaming_api_base_url}'; window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))} = javascript_include_tag 'application' diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl index 0e9736f5f..71949ab0e 100644 --- a/app/views/home/initial_state.json.rabl +++ b/app/views/home/initial_state.json.rabl @@ -1,24 +1,24 @@ object false -node(:meta) { +node(:meta) do { access_token: @token, locale: I18n.locale, me: current_account.id, } -} +end -node(:compose) { +node(:compose) do { me: current_account.id, - private: current_account.locked?, + default_privacy: current_account.user.setting_default_privacy, } -} +end -node(:accounts) { +node(:accounts) do { current_account.id => partial('api/v1/accounts/show', object: current_account), } -} +end node(:settings) { @web_settings } diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 747977f9c..aee0540d2 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -7,6 +7,8 @@ .fields-group = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) } + = f.input :setting_default_privacy, collection: Status.visibilities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false + .fields-group = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| = ff.input :follow, as: :boolean, wrapper: :with_label diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb index f423d43ae..f4e738d80 100644 --- a/app/workers/distribution_worker.rb +++ b/app/workers/distribution_worker.rb @@ -4,7 +4,10 @@ class DistributionWorker include Sidekiq::Worker def perform(status_id) - FanOutOnWriteService.new.call(Status.find(status_id)) + status = Status.find(status_id) + + FanOutOnWriteService.new.call(status) + WarmCacheService.new.call(status) rescue ActiveRecord::RecordNotFound true end diff --git a/config/environments/development.rb b/config/environments/development.rb index 51cb43e5d..6157f20d3 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -20,11 +20,12 @@ Rails.application.configure do host: ENV['REDIS_HOST'] || 'localhost', port: ENV['REDIS_PORT'] || 6379, db: 0, - namespace: 'cache' + namespace: 'cache', + expires_in: 1.minute, } config.public_file_server.headers = { - 'Cache-Control' => 'public, max-age=172800' + 'Cache-Control' => 'public, max-age=172800', } else config.action_controller.perform_caching = false diff --git a/config/environments/production.rb b/config/environments/production.rb index eaddba522..62ea217ef 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -64,7 +64,7 @@ Rails.application.configure do password: ENV.fetch('REDIS_PASSWORD') { false }, db: 0, namespace: 'cache', - expires_in: 20.minutes + expires_in: 20.minutes, } # Enable serving of images, stylesheets, and JavaScripts from an asset server. diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb index faa9940b0..fb0b8b7fe 100644 --- a/config/initializers/ostatus.rb +++ b/config/initializers/ostatus.rb @@ -10,8 +10,10 @@ Rails.application.configure do config.x.use_s3 = ENV['S3_ENABLED'] == 'true' config.action_mailer.default_url_options = { host: host, protocol: https ? 'https://' : 'http://', trailing_slash: false } + config.x.streaming_api_base_url = 'http://localhost:4000' if Rails.env.production? config.action_cable.allowed_request_origins = ["http#{https ? 's' : ''}://#{host}"] + config.x.streaming_api_base_url = ENV.fetch('STREAMING_API_BASE_URL') { "http#{https ? 's' : ''}://#{host}" } end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 16b406745..b1b1e7995 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -97,8 +97,12 @@ en: settings: Settings two_factor_auth: Two-factor Authentication statuses: - over_character_limit: character limit of %{max} exceeded open_in_web: Open in web + over_character_limit: character limit of %{max} exceeded + visibilities: + private: Only show to followers + public: Public + unlisted: Public, but do not display on the public timeline stream_entries: click_to_show: Click to show favourited: favourited a post by diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 09957c914..4d1758f82 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -23,6 +23,7 @@ en: note: Bio otp_attempt: Two-factor code password: Password + setting_default_privacy: Post privacy username: Username interactions: must_be_follower: Block notifications from non-followers diff --git a/config/routes.rb b/config/routes.rb index 699f56833..e17d54995 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -117,9 +117,6 @@ Rails.application.routes.draw do resources :blocks, only: [:index] resources :favourites, only: [:index] - post '/devices/register', to: 'devices#register', as: :register_device - post '/devices/unregister', to: 'devices#unregister', as: :unregister_device - resources :follow_requests, only: [:index] do member do post :authorize diff --git a/db/migrate/20170205175257_remove_devices.rb b/db/migrate/20170205175257_remove_devices.rb new file mode 100644 index 000000000..e96ffed4d --- /dev/null +++ b/db/migrate/20170205175257_remove_devices.rb @@ -0,0 +1,5 @@ +class RemoveDevices < ActiveRecord::Migration[5.0] + def change + drop_table :devices + end +end diff --git a/db/schema.rb b/db/schema.rb index 448a7b861..28a578aa2 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: 20170129000348) do +ActiveRecord::Schema.define(version: 20170205175257) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -54,15 +54,6 @@ ActiveRecord::Schema.define(version: 20170129000348) do t.index ["account_id", "target_account_id"], name: "index_blocks_on_account_id_and_target_account_id", unique: true, using: :btree end - create_table "devices", force: :cascade do |t| - t.integer "account_id", null: false - t.string "registration_id", default: "", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_devices_on_account_id", using: :btree - t.index ["registration_id"], name: "index_devices_on_registration_id", using: :btree - end - create_table "domain_blocks", force: :cascade do |t| t.string "domain", default: "", null: false t.datetime "created_at", null: false diff --git a/docker-compose.yml b/docker-compose.yml index e1f1f1c4c..e6002eaa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,16 @@ services: volumes: - ./public/assets:/mastodon/public/assets - ./public/system:/mastodon/public/system + streaming: + restart: always + build: . + env_file: .env.production + command: npm run start + ports: + - "4000:4000" + depends_on: + - db + - redis sidekiq: restart: always build: . diff --git a/docs/Running-Mastodon/Production-guide.md b/docs/Running-Mastodon/Production-guide.md index 76964d995..ff4427dd2 100644 --- a/docs/Running-Mastodon/Production-guide.md +++ b/docs/Running-Mastodon/Production-guide.md @@ -49,6 +49,22 @@ server { tcp_nodelay on; } + location /api/v1/streaming { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + proxy_pass http://localhost:4000; + proxy_buffering off; + proxy_redirect off; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + tcp_nodelay on; + } + error_page 500 501 502 503 504 /500.html; } ``` @@ -162,6 +178,27 @@ Restart=always WantedBy=multi-user.target ``` +Example systemd configuration file for the streaming API, to be placed in `/etc/systemd/system/mastodon-streaming.service`: + +```systemd +[Unit] +Description=mastodon-streaming +After=network.target + +[Service] +Type=simple +User=mastodon +WorkingDirectory=/home/mastodon/live +Environment="NODE_ENV=production" +Environment="PORT=4000" +ExecStart=/usr/bin/npm run start +TimeoutSec=15 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + This allows you to `sudo systemctl enable mastodon-*.service` and `sudo systemctl start mastodon-*.service` to get things going. ## Cronjobs diff --git a/docs/Using-the-API/API.md b/docs/Using-the-API/API.md index 51b465927..07c1b25a9 100644 --- a/docs/Using-the-API/API.md +++ b/docs/Using-the-API/API.md @@ -222,22 +222,6 @@ Creates a new OAuth app. Returns `id`, `client_id` and `client_secret` which can These values should be requested in the app itself from the API for each new app install + mastodon domain combo, and stored in the app for future requests. -**POST /api/v1/devices/register** - -Form data: - -- `registration_id`: Device token (also called registration token/registration ID) - -Apps can use Firebase Cloud Messaging to receive push notifications from the instances, given that the instance admin has acquired a Firebase API key. More in [push notifications](Push-notifications.md). This method requires a user context, i.e. your app will receive notifications for the authorized user. - -**POST /api/v1/devices/unregister** - -Form data: - -- `registration_id`: Device token (also called registration token/registration ID) - -To remove the device from receiving push notifications for the user. - ___ ## Entities diff --git a/docs/Using-the-API/Push-notifications.md b/docs/Using-the-API/Push-notifications.md index fd50a75bd..d98c8833a 100644 --- a/docs/Using-the-API/Push-notifications.md +++ b/docs/Using-the-API/Push-notifications.md @@ -2,18 +2,3 @@ Push notifications ================== **Note: This push notification design turned out to not be fully operational on the side of Firebase. A different approach is in consideration** - -Mastodon can communicate with the Firebase Cloud Messaging API to send push notifications to apps on users' devices. For this to work, these conditions must be met: - -* Responsibility of an instance owner: `FCM_API_KEY` set on the instance. This can be obtained on the Firebase dashboard, in project settings, under Cloud Messaging, as "server key" -* Responsibility of the app developer: Firebase added/enabled in the Android/iOS app. [See Guide](https://firebase.google.com/docs/cloud-messaging/) - -When the app obtains/refreshes a registration ID from Firebase, it needs to send that ID to the `/api/v1/devices/register` endpoint of the authorized user's instance via a POST request. The app can opt out of notifications by sending a similiar request with `unregister` instead of `register`. - -The push notifications will be triggered by the notifications of the type you can normally find in `/api/v1/notifications`. However, the push notifications will not contain any inline content. They will contain JSON data of this format ("12" is an example value): - -```json -{ "notification_id": 12 } -``` - -Your app can then retrieve the actual content of the notification from the `/api/v1/notifications/12` API endpoint. diff --git a/package.json b/package.json index 9685f07a4..9f2bd3df9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "babelify": "^7.3.0", "browserify": "^13.1.0", "browserify-incremental": "^3.1.1", + "bufferutil": "^2.0.0", "chai": "^3.5.0", "chai-enzyme": "^0.5.2", "css-loader": "^0.26.1", @@ -64,6 +65,10 @@ "sass-loader": "^4.0.2", "sinon": "^1.17.6", "style-loader": "^0.13.1", - "webpack": "^1.14.0" + "utf-8-validate": "^3.0.0", + "uuid": "^3.0.1", + "webpack": "^1.14.0", + "websocket.js": "^0.1.7", + "ws": "^2.0.2" } } diff --git a/spec/controllers/api/v1/devices_controller_spec.rb b/spec/controllers/api/v1/devices_controller_spec.rb deleted file mode 100644 index 745a462e3..000000000 --- a/spec/controllers/api/v1/devices_controller_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'rails_helper' - -RSpec.describe Api::V1::DevicesController, type: :controller do - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - let(:token) { double acceptable?: true, resource_owner_id: user.id } - - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'POST #register' do - before do - post :register, params: { registration_id: 'foo123' } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'registers device' do - expect(Device.where(account: user.account, registration_id: 'foo123').first).to_not be_nil - end - end - - describe 'POST #unregister' do - before do - post :unregister, params: { registration_id: 'foo123' } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'removes device' do - expect(Device.where(account: user.account, registration_id: 'foo123').first).to be_nil - end - end -end diff --git a/spec/fabricators/device_fabricator.rb b/spec/fabricators/device_fabricator.rb deleted file mode 100644 index 02b24e8b3..000000000 --- a/spec/fabricators/device_fabricator.rb +++ /dev/null @@ -1,3 +0,0 @@ -Fabricator(:device) do - registration_id "12345678" -end diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb index 6ec28f5d8..0db1634e9 100644 --- a/spec/lib/formatter_spec.rb +++ b/spec/lib/formatter_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Formatter do end it 'contains a link' do - expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com"><span class="invisible">http://</span><span class="ellipsis">google.com</span><span class="invisible"></span></a>') + expect(subject).to match('<a rel="nofollow noopener" target="_blank" href="http://google.com"><span class="invisible">http://</span><span class="">google.com</span><span class="invisible"></span></a>') end end diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb deleted file mode 100644 index f56fbf978..000000000 --- a/spec/models/device_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -RSpec.describe Device, type: :model do - -end diff --git a/streaming/index.js b/streaming/index.js index 43d8895f1..e2e8f943e 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -1,8 +1,12 @@ import dotenv from 'dotenv' import express from 'express' +import http from 'http' import redis from 'redis' import pg from 'pg' import log from 'npmlog' +import url from 'url' +import WebSocket from 'ws' +import uuid from 'uuid' const env = process.env.NODE_ENV || 'development' @@ -27,21 +31,27 @@ const pgConfigs = { } } -const app = express() +const app = express() const pgPool = new pg.Pool(pgConfigs[env]) +const server = http.createServer(app) +const wss = new WebSocket.Server({ server }) -const authenticationMiddleware = (req, res, next) => { - const authorization = req.get('Authorization') +const allowCrossDomain = (req, res, next) => { + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Headers', 'Authorization, Accept, Cache-Control') + res.header('Access-Control-Allow-Methods', 'GET, OPTIONS') - if (!authorization) { - err = new Error('Missing access token') - err.statusCode = 401 + next() +} - return next(err) - } +const setRequestId = (req, res, next) => { + req.requestId = uuid.v4() + res.header('X-Request-Id', req.requestId) - const token = authorization.replace(/^Bearer /, '') + next() +} +const accountFromToken = (token, req, next) => { pgPool.connect((err, client, done) => { if (err) { return next(err) @@ -68,28 +78,46 @@ const authenticationMiddleware = (req, res, next) => { }) } +const authenticationMiddleware = (req, res, next) => { + if (req.method === 'OPTIONS') { + return next() + } + + const authorization = req.get('Authorization') + + if (!authorization) { + const err = new Error('Missing access token') + err.statusCode = 401 + + return next(err) + } + + const token = authorization.replace(/^Bearer /, '') + + accountFromToken(token, req, next) +} + const errorMiddleware = (err, req, res, next) => { - log.error(err) + log.error(req.requestId, err) res.writeHead(err.statusCode || 500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occured' })) + res.end(JSON.stringify({ error: err.statusCode ? `${err}` : 'An unexpected error occurred' })) } const placeholders = (arr, shift = 0) => arr.map((_, i) => `$${i + 1 + shift}`).join(', '); -const streamFrom = (id, req, res, needsFiltering = false) => { - log.verbose(`Starting stream from ${id} for ${req.accountId}`) +const streamFrom = (redisClient, id, req, output, needsFiltering = false) => { + log.verbose(req.requestId, `Starting stream from ${id} for ${req.accountId}`) - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Transfer-Encoding', 'chunked') + redisClient.on('message', (channel, message) => { + const { event, payload, queued_at } = JSON.parse(message) - const redisClient = redis.createClient({ - host: process.env.REDIS_HOST || '127.0.0.1', - port: process.env.REDIS_PORT || 6379, - password: process.env.REDIS_PASSWORD - }) + const transmit = () => { + const now = new Date().getTime() + const delta = now - queued_at; - redisClient.on('message', (channel, message) => { - const { event, payload } = JSON.parse(message) + log.silly(req.requestId, `Transmitting for ${req.accountId}: ${event} ${payload} Delay: ${delta}ms`) + output(event, payload) + } // Only messages that may require filtering are statuses, since notifications // are already personalized and deletes do not matter @@ -115,35 +143,127 @@ const streamFrom = (id, req, res, needsFiltering = false) => { return } - res.write(`event: ${event}\n`) - res.write(`data: ${payload}\n\n`) + transmit() }) }) } else { - res.write(`event: ${event}\n`) - res.write(`data: ${payload}\n\n`) + transmit() } }) + redisClient.subscribe(id) +} + +// Setup stream output to HTTP +const streamToHttp = (req, res, redisClient) => { + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Transfer-Encoding', 'chunked') + const heartbeat = setInterval(() => res.write(':thump\n'), 15000) req.on('close', () => { - log.verbose(`Ending stream from ${id} for ${req.accountId}`) + log.verbose(req.requestId, `Ending stream for ${req.accountId}`) clearInterval(heartbeat) redisClient.quit() }) - redisClient.subscribe(id) + return (event, payload) => { + res.write(`event: ${event}\n`) + res.write(`data: ${payload}\n\n`) + } +} + +// Setup stream output to WebSockets +const streamToWs = (req, ws, redisClient) => { + ws.on('close', () => { + log.verbose(req.requestId, `Ending stream for ${req.accountId}`) + redisClient.quit() + }) + + return (event, payload) => { + if (ws.readyState !== ws.OPEN) { + log.error(req.requestId, 'Tried writing to closed socket') + return + } + + ws.send(JSON.stringify({ event, payload })) + } } +// Get new redis connection +const getRedisClient = () => redis.createClient({ + host: process.env.REDIS_HOST || '127.0.0.1', + port: process.env.REDIS_PORT || 6379, + password: process.env.REDIS_PASSWORD +}) + +app.use(setRequestId) +app.use(allowCrossDomain) app.use(authenticationMiddleware) app.use(errorMiddleware) -app.get('/api/v1/streaming/user', (req, res) => streamFrom(`timeline:${req.accountId}`, req, res)) -app.get('/api/v1/streaming/public', (req, res) => streamFrom('timeline:public', req, res, true)) -app.get('/api/v1/streaming/hashtag', (req, res) => streamFrom(`timeline:hashtag:${req.params.tag}`, req, res, true)) +app.get('/api/v1/streaming/user', (req, res) => { + const redisClient = getRedisClient() + streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToHttp(req, res, redisClient)) +}) + +app.get('/api/v1/streaming/public', (req, res) => { + const redisClient = getRedisClient() + streamFrom(redisClient, 'timeline:public', req, streamToHttp(req, res, redisClient), true) +}) + +app.get('/api/v1/streaming/public/local', (req, res) => { + const redisClient = getRedisClient() + streamFrom(redisClient, 'timeline:public:local', req, streamToHttp(req, res, redisClient), true) +}) + +app.get('/api/v1/streaming/hashtag', (req, res) => { + const redisClient = getRedisClient() + streamFrom(redisClient, `timeline:hashtag:${req.params.tag}`, req, streamToHttp(req, res, redisClient), true) +}) + +app.get('/api/v1/streaming/hashtag/local', (req, res) => { + const redisClient = getRedisClient() + streamFrom(redisClient, `timeline:hashtag:${req.params.tag}:local`, req, streamToHttp(req, res, redisClient), true) +}) + +wss.on('connection', ws => { + const location = url.parse(ws.upgradeReq.url, true) + const token = location.query.access_token + const req = { requestId: uuid.v4() } + + accountFromToken(token, req, err => { + if (err) { + log.error(req.requestId, err) + ws.close() + return + } -log.level = 'verbose' -log.info(`Starting HTTP server on port ${process.env.PORT || 4000}`) + const redisClient = getRedisClient() + + switch(location.query.stream) { + case 'user': + streamFrom(redisClient, `timeline:${req.accountId}`, req, streamToWs(req, ws, redisClient)) + break; + case 'public': + streamFrom(redisClient, 'timeline:public', req, streamToWs(req, ws, redisClient), true) + break; + case 'public:local': + streamFrom(redisClient, 'timeline:public:local', req, streamToWs(req, ws, redisClient), true) + break; + case 'hashtag': + streamFrom(redisClient, `timeline:hashtag:${location.query.tag}`, req, streamToWs(req, ws, redisClient), true) + break; + case 'hashtag:local': + streamFrom(redisClient, `timeline:hashtag:${location.query.tag}:local`, req, streamToWs(req, ws, redisClient), true) + break; + default: + ws.close() + } + }) +}) -app.listen(process.env.PORT || 4000) +server.listen(process.env.PORT || 4000, () => { + log.level = process.env.LOG_LEVEL || 'verbose' + log.info(`Starting streaming API server on port ${server.address().port}`) +}) diff --git a/yarn.lock b/yarn.lock index bd1747929..89236d45a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1237,6 +1237,12 @@ babylon@^6.15.0: version "6.15.0" resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.15.0.tgz#ba65cfa1a80e1759b0e89fb562e27dccae70348e" +backoff@^2.4.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/backoff/-/backoff-2.5.0.tgz#f616eda9d3e4b66b8ca7fca79f695722c5f8e26f" + dependencies: + precond "0.2" + balanced-match@^0.4.1, balanced-match@^0.4.2: version "0.4.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" @@ -1263,6 +1269,10 @@ binary-extensions@^1.0.0: version "1.7.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.7.0.tgz#6c1610db163abfb34edfe42fa423343a1e01185d" +bindings@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.2.1.tgz#14ad6113812d2d37d72e67b4cacb4bb726505f11" + bl@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/bl/-/bl-1.1.2.tgz#fdca871a99713aa00d19e3bbba41c44787a65398" @@ -1479,6 +1489,13 @@ buffer@^4.1.0, buffer@^4.9.0: ieee754 "^1.1.4" isarray "^1.0.0" +bufferutil@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-2.0.0.tgz#6588ed4bafa300798b26dc048494a51abde83507" + dependencies: + bindings "~1.2.1" + nan "~2.5.0" + builtin-modules@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" @@ -3664,9 +3681,9 @@ ms@0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" -nan@^2.3.0, nan@^2.3.2: - version "2.4.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.4.0.tgz#fb3c59d45fe4effe215f0b890f8adf6eb32d2232" +nan@^2.3.0, nan@^2.3.2, nan@~2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2" negotiator@0.6.1: version "0.6.1" @@ -3808,16 +3825,7 @@ normalize-url@^1.4.0: gauge "~2.6.0" set-blocking "~2.0.0" -npmlog@4.x, npmlog@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.0.tgz#e094503961c70c1774eb76692080e8d578a9f88f" - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.6.0" - set-blocking "~2.0.0" - -npmlog@^4.0.2: +npmlog@4.x, npmlog@^4.0.0, npmlog@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.0.2.tgz#d03950e0e78ce1527ba26d2a7592e9348ac3e75f" dependencies: @@ -4401,6 +4409,10 @@ postgres-interval@~1.0.0: dependencies: xtend "^4.0.0" +precond@0.2: + version "0.2.3" + resolved "https://registry.yarnpkg.com/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -5556,6 +5568,10 @@ uid-number@~0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" +ultron@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864" + umd@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.1.tgz#8ae556e11011f63c2596708a8837259f01b3d60e" @@ -5603,6 +5619,13 @@ user-home@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" +utf-8-validate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-3.0.0.tgz#42e54dfbc7cdfbd1d3bbf0a2f5000b4c6aeaa0c9" + dependencies: + bindings "~1.2.1" + nan "~2.5.0" + util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -5621,6 +5644,10 @@ uuid@^2.0.1, uuid@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" +uuid@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" + v8flags@^2.0.10: version "2.0.11" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.0.11.tgz#bca8f30f0d6d60612cc2c00641e6962d42ae6881" @@ -5727,6 +5754,12 @@ webpack@^1.13.1, webpack@^1.14.0: watchpack "^0.2.1" webpack-core "~0.6.9" +websocket.js@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/websocket.js/-/websocket.js-0.1.7.tgz#8d24cefb1a080c259e7e4740c02cab8f142df2b0" + dependencies: + backoff "^2.4.1" + whatwg-fetch@>=0.10.0: version "1.0.0" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-1.0.0.tgz#01c2ac4df40e236aaa18480e3be74bd5c8eb798e" @@ -5803,6 +5836,12 @@ write-file-atomic@^1.1.2: imurmurhash "^0.1.4" slide "^1.1.5" +ws@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-2.0.2.tgz#6257d1a679f0cb23658cba3dcad1316e2b1000c5" + dependencies: + ultron "~1.1.0" + xdg-basedir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2" |