diff options
Diffstat (limited to 'app')
61 files changed, 626 insertions, 232 deletions
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 |