diff options
58 files changed, 880 insertions, 148 deletions
diff --git a/app/assets/javascripts/components/actions/alerts.jsx b/app/assets/javascripts/components/actions/alerts.jsx new file mode 100644 index 000000000..086e0727e --- /dev/null +++ b/app/assets/javascripts/components/actions/alerts.jsx @@ -0,0 +1,24 @@ +export const ALERT_SHOW = 'ALERT_SHOW'; +export const ALERT_DISMISS = 'ALERT_DISMISS'; +export const ALERT_CLEAR = 'ALERT_CLEAR'; + +export function dismissAlert(alert) { + return { + type: ALERT_DISMISS, + alert + }; +}; + +export function clearAlert() { + return { + type: ALERT_CLEAR + }; +}; + +export function showAlert(title, message) { + return { + type: ALERT_SHOW, + title, + message + }; +}; diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index b77a9c727..af3cdbf30 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -19,6 +19,9 @@ export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; +export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; +export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -26,10 +29,16 @@ export function changeCompose(text) { }; }; -export function replyCompose(status) { - return { - type: COMPOSE_REPLY, - status: status +export function replyCompose(status, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_REPLY, + status: status + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } }; }; @@ -176,3 +185,15 @@ export function selectComposeSuggestion(position, accountId) { }); }; }; + +export function mountCompose() { + return { + type: COMPOSE_MOUNT + }; +}; + +export function unmountCompose() { + return { + type: COMPOSE_UNMOUNT + }; +}; diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index 79457eba6..a03f88af1 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -1,24 +1,133 @@ -export const NOTIFICATION_SHOW = 'NOTIFICATION_SHOW'; -export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS'; -export const NOTIFICATION_CLEAR = 'NOTIFICATION_CLEAR'; +import api, { getLinks } from '../api' +import Immutable from 'immutable'; +import IntlMessageFormat from 'intl-messageformat'; -export function dismissNotification(notification) { +import { fetchRelationships } from './accounts'; + +export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; + +export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; +export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; +export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; + +export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; +export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; +export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; + +const fetchRelatedRelationships = (dispatch, notifications) => { + const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); + + if (accountIds > 0) { + dispatch(fetchRelationships(accountIds)); + } +}; + +export function updateNotifications(notification, intlMessages, intlLocale) { + return dispatch => { + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + account: notification.account, + status: notification.status + }); + + fetchRelatedRelationships(dispatch, [notification]); + + // Desktop notifications + if (typeof window.Notification !== 'undefined') { + const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + const body = $('<p>').html(notification.status ? notification.status.content : '').text(); + + new Notification(title, { body, icon: notification.account.avatar }); + } + }; +}; + +export function refreshNotifications() { + return (dispatch, getState) => { + dispatch(refreshNotificationsRequest()); + + const params = {}; + const ids = getState().getIn(['notifications', 'items']); + + if (ids.size > 0) { + params.since_id = ids.first().get('id'); + } + + api(getState).get('/api/v1/notifications', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(refreshNotificationsSuccess(response.data, next ? next.uri : null)); + fetchRelatedRelationships(dispatch, response.data); + }).catch(error => { + dispatch(refreshNotificationsFail(error)); + }); + }; +}; + +export function refreshNotificationsRequest() { + return { + type: NOTIFICATIONS_REFRESH_REQUEST + }; +}; + +export function refreshNotificationsSuccess(notifications, next) { + return { + type: NOTIFICATIONS_REFRESH_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status), + next + }; +}; + +export function refreshNotificationsFail(error) { + return { + type: NOTIFICATIONS_REFRESH_FAIL, + error + }; +}; + +export function expandNotifications() { + return (dispatch, getState) => { + const url = getState().getIn(['notifications', 'next'], null); + + if (url === null) { + return; + } + + dispatch(expandNotificationsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); + fetchRelatedRelationships(dispatch, response.data); + }).catch(error => { + dispatch(expandNotificationsFail(error)); + }); + }; +}; + +export function expandNotificationsRequest() { return { - type: NOTIFICATION_DISMISS, - notification: notification + type: NOTIFICATIONS_EXPAND_REQUEST }; }; -export function clearNotifications() { +export function expandNotificationsSuccess(notifications, next) { return { - type: NOTIFICATION_CLEAR + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status), + next }; }; -export function showNotification(title, message) { +export function expandNotificationsFail(error) { return { - type: NOTIFICATION_SHOW, - title: title, - message: message + type: NOTIFICATIONS_EXPAND_FAIL, + error }; }; diff --git a/app/assets/javascripts/components/api.jsx b/app/assets/javascripts/components/api.jsx index f674290ab..080c2bd6a 100644 --- a/app/assets/javascripts/components/api.jsx +++ b/app/assets/javascripts/components/api.jsx @@ -2,7 +2,13 @@ import axios from 'axios'; import LinkHeader from 'http-link-header'; export const getLinks = response => { - return LinkHeader.parse(response.headers.link); + const value = response.headers.link; + + if (!value) { + return { refs: [] }; + } + + return LinkHeader.parse(value); }; export default getState => axios.create({ diff --git a/app/assets/javascripts/components/features/followers/components/account.jsx b/app/assets/javascripts/components/components/account.jsx index 4a1fca6da..413a956b9 100644 --- a/app/assets/javascripts/components/features/followers/components/account.jsx +++ b/app/assets/javascripts/components/components/account.jsx @@ -1,9 +1,9 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Avatar from '../../../components/avatar'; -import DisplayName from '../../../components/display_name'; +import Avatar from './avatar'; +import DisplayName from './display_name'; import { Link } from 'react-router'; -import IconButton from '../../../components/icon_button'; +import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx index 2d463b9d1..84cd07527 100644 --- a/app/assets/javascripts/components/components/status.jsx +++ b/app/assets/javascripts/components/components/status.jsx @@ -11,6 +11,15 @@ import { FormattedMessage } from 'react-intl'; import emojify from '../emoji'; import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser'; +const outerStyle = { + padding: '8px 10px', + paddingLeft: '68px', + position: 'relative', + minHeight: '48px', + borderBottom: '1px solid #363c4b', + cursor: 'default' +}; + const Status = React.createClass({ contextTypes: { @@ -26,7 +35,7 @@ const Status = React.createClass({ onDelete: React.PropTypes.func, onOpenMedia: React.PropTypes.func, me: React.PropTypes.number, - now: React.PropTypes.any + muted: React.PropTypes.bool }, mixins: [PureRenderMixin], @@ -81,14 +90,14 @@ const Status = React.createClass({ } return ( - <div style={{ padding: '8px 10px', paddingLeft: '68px', position: 'relative', minHeight: '48px', borderBottom: '1px solid #363c4b', cursor: 'default' }}> + <div className={this.props.muted ? 'muted' : ''} style={outerStyle}> <div style={{ fontSize: '15px' }}> <div style={{ float: 'right', fontSize: '14px' }}> <a href={status.get('url')} className='status__relative-time' style={{ color: '#616b86' }} target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} now={now} /></a> </div> <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#616b86' }}> - <div style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}> + <div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}> <Avatar src={status.getIn(['account', 'avatar'])} size={48} /> </div> diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx index 051b898bd..dec1decff 100644 --- a/app/assets/javascripts/components/components/status_action_bar.jsx +++ b/app/assets/javascripts/components/components/status_action_bar.jsx @@ -13,6 +13,11 @@ const messages = defineMessages({ }); const StatusActionBar = React.createClass({ + + contextTypes: { + router: React.PropTypes.object + }, + propTypes: { status: ImmutablePropTypes.map.isRequired, onReply: React.PropTypes.func, @@ -25,7 +30,7 @@ const StatusActionBar = React.createClass({ mixins: [PureRenderMixin], handleReplyClick () { - this.props.onReply(this.props.status); + this.props.onReply(this.props.status, this.context.router); }, handleFavouriteClick () { diff --git a/app/assets/javascripts/components/features/followers/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx index c5d5c5881..1f49f9819 100644 --- a/app/assets/javascripts/components/features/followers/containers/account_container.jsx +++ b/app/assets/javascripts/components/containers/account_container.jsx @@ -1,10 +1,10 @@ -import { connect } from 'react-redux'; -import { makeGetAccount } from '../../../selectors'; -import Account from '../components/account'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../selectors'; +import Account from '../components/account'; import { followAccount, unfollowAccount -} from '../../../actions/accounts'; +} from '../actions/accounts'; const makeMapStateToProps = () => { const getAccount = makeGetAccount(); diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index b2c978ee8..3528ef81c 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -6,6 +6,7 @@ import { deleteFromTimelines, refreshTimeline } from '../actions/timelines'; +import { updateNotifications } from '../actions/notifications'; import { setAccessToken } from '../actions/meta'; import { setAccountSelf } from '../actions/accounts'; import PureRenderMixin from 'react-addons-pure-render-mixin'; @@ -32,6 +33,7 @@ import Following from '../features/following'; import Reblogs from '../features/reblogs'; import Favourites from '../features/favourites'; import HashtagTimeline from '../features/hashtag_timeline'; +import Notifications from '../features/notifications'; import { IntlProvider, addLocaleData } from 'react-intl'; import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; @@ -58,8 +60,10 @@ const Mastodon = React.createClass({ mixins: [PureRenderMixin], componentWillMount() { - store.dispatch(setAccessToken(this.props.token)); - store.dispatch(setAccountSelf(JSON.parse(this.props.account))); + const { token, account, locale } = this.props; + + store.dispatch(setAccessToken(token)); + store.dispatch(setAccountSelf(JSON.parse(account))); if (typeof App !== 'undefined') { this.subscription = App.cable.subscriptions.create('TimelineChannel', { @@ -75,11 +79,18 @@ const Mastodon = React.createClass({ return store.dispatch(refreshTimeline('home', true)); case 'block': return store.dispatch(refreshTimeline('mentions', true)); + case 'notification': + return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale)); } } }); } + + // Desktop notifications + if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { + Notification.requestPermission(); + } }, componentWillUnmount () { @@ -103,6 +114,8 @@ const Mastodon = React.createClass({ <Route path='timelines/public' component={PublicTimeline} /> <Route path='timelines/tag/:id' component={HashtagTimeline} /> + <Route path='notifications' component={Notifications} /> + <Route path='statuses/new' component={Compose} /> <Route path='statuses/:statusId' component={Status} /> <Route path='statuses/:statusId/reblogs' component={Reblogs} /> diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx index 2bcb7026c..28756b5ef 100644 --- a/app/assets/javascripts/components/containers/status_container.jsx +++ b/app/assets/javascripts/components/containers/status_container.jsx @@ -61,8 +61,8 @@ const makeMapStateToPropsLast = () => { const mapDispatchToProps = (dispatch) => ({ - onReply (status) { - dispatch(replyCompose(status)); + onReply (status, router) { + dispatch(replyCompose(status, router)); }, onReblog (status) { diff --git a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx index 697902275..6850629ba 100644 --- a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx +++ b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx @@ -1,6 +1,6 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import AccountContainer from '../../followers/containers/account_container'; +import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; const outerStyle = { diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index a50118bef..5c1b22e00 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -1,12 +1,13 @@ -import Drawer from './components/drawer'; +import Drawer from './components/drawer'; import ComposeFormContainer from './containers/compose_form_container'; -import UploadFormContainer from './containers/upload_form_container'; -import NavigationContainer from './containers/navigation_container'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; +import UploadFormContainer from './containers/upload_form_container'; +import NavigationContainer from './containers/navigation_container'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; import SuggestionsContainer from './containers/suggestions_container'; -import SearchContainer from './containers/search_container'; +import SearchContainer from './containers/search_container'; import { fetchSuggestions } from '../../actions/suggestions'; -import { connect } from 'react-redux'; +import { connect } from 'react-redux'; +import { mountCompose, unmountCompose } from '../../actions/compose'; const Compose = React.createClass({ @@ -17,9 +18,14 @@ const Compose = React.createClass({ mixins: [PureRenderMixin], componentDidMount () { + this.props.dispatch(mountCompose()); this.props.dispatch(fetchSuggestions()); }, + componentWillUnmount () { + this.props.dispatch(unmountCompose()); + }, + render () { return ( <Drawer> diff --git a/app/assets/javascripts/components/features/favourites/index.jsx b/app/assets/javascripts/components/features/favourites/index.jsx index 5c9ea498b..994803175 100644 --- a/app/assets/javascripts/components/features/favourites/index.jsx +++ b/app/assets/javascripts/components/features/favourites/index.jsx @@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchFavourites } from '../../actions/interactions'; import { ScrollContainer } from 'react-router-scroll'; -import AccountContainer from '../followers/containers/account_container'; +import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; diff --git a/app/assets/javascripts/components/features/followers/index.jsx b/app/assets/javascripts/components/features/followers/index.jsx index 13eed69ca..38755d862 100644 --- a/app/assets/javascripts/components/features/followers/index.jsx +++ b/app/assets/javascripts/components/features/followers/index.jsx @@ -7,7 +7,7 @@ import { expandFollowers } from '../../actions/accounts'; import { ScrollContainer } from 'react-router-scroll'; -import AccountContainer from './containers/account_container'; +import AccountContainer from '../../containers/account_container'; const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']) diff --git a/app/assets/javascripts/components/features/following/index.jsx b/app/assets/javascripts/components/features/following/index.jsx index 865b39736..c4ec7bb67 100644 --- a/app/assets/javascripts/components/features/following/index.jsx +++ b/app/assets/javascripts/components/features/following/index.jsx @@ -7,7 +7,7 @@ import { expandFollowing } from '../../actions/accounts'; import { ScrollContainer } from 'react-router-scroll'; -import AccountContainer from '../followers/containers/account_container'; +import AccountContainer from '../../containers/account_container'; const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']) diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx new file mode 100644 index 000000000..be8cf3616 --- /dev/null +++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx @@ -0,0 +1,79 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import StatusContainer from '../../../containers/status_container'; +import AccountContainer from '../../../containers/account_container'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; + +const messageStyle = { + padding: '8px 10px', + paddingBottom: '0', + cursor: 'default', + color: '#d9e1e8', + fontSize: '15px' +}; + +const linkStyle = { + fontWeight: '500' +}; + +const Notification = React.createClass({ + + propTypes: { + notification: ImmutablePropTypes.map.isRequired + }, + + mixins: [PureRenderMixin], + + renderFollow (account, link) { + return ( + <div className='notification'> + <div style={messageStyle}><i className='fa fa-fw fa-user-plus' style={{ color: '#2b90d9' }} /> <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /></div> + <AccountContainer id={account.get('id')} withNote={false} /> + </div> + ); + }, + + renderMention (notification) { + return <StatusContainer id={notification.get('status')} />; + }, + + renderFavourite (notification, link) { + return ( + <div className='notification'> + <div style={messageStyle}><i className='fa fa-fw fa-star' style={{ color: '#ca8f04' }} /> <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /></div> + <StatusContainer id={notification.get('status')} muted={true} /> + </div> + ); + }, + + renderReblog (notification, link) { + return ( + <div className='notification'> + <div style={messageStyle}><i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} /> <FormattedMessage id='notification.reblog' defaultMessage='{name} reblogged your status' values={{ name: link }} /></div> + <StatusContainer id={notification.get('status')} muted={true} /> + </div> + ); + }, + + render () { + const { notification } = this.props; + const account = notification.get('account'); + const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); + const link = <Link className='notification__display-name' style={linkStyle} to={`/accounts/${account.get('id')}`}>{displayName}</Link>; + + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(account, link); + case 'mention': + return this.renderMention(notification); + case 'favourite': + return this.renderFavourite(notification, link); + case 'reblog': + return this.renderReblog(notification, link); + } + } + +}); + +export default Notification; diff --git a/app/assets/javascripts/components/features/notifications/containers/notification_container.jsx b/app/assets/javascripts/components/features/notifications/containers/notification_container.jsx new file mode 100644 index 000000000..4ca1b1b7b --- /dev/null +++ b/app/assets/javascripts/components/features/notifications/containers/notification_container.jsx @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { makeGetNotification } from '../../../selectors'; +import Notification from '../components/notification'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(Notification); diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx new file mode 100644 index 000000000..00feeece7 --- /dev/null +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -0,0 +1,74 @@ +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 { + refreshNotifications, + expandNotifications +} from '../../actions/notifications'; +import NotificationContainer from './containers/notification_container'; +import { ScrollContainer } from 'react-router-scroll'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' } +}); + +const mapStateToProps = state => ({ + notifications: state.getIn(['notifications', 'items']) +}); + +const Notifications = React.createClass({ + + propTypes: { + notifications: ImmutablePropTypes.list.isRequired, + dispatch: React.PropTypes.func.isRequired, + trackScroll: React.PropTypes.bool + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + const { dispatch } = this.props; + dispatch(refreshNotifications()); + }, + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandNotifications()); + } + }, + + render () { + const { intl, notifications, trackScroll } = this.props; + + const scrollableArea = ( + <div className='scrollable' onScroll={this.handleScroll}> + <div> + {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} + </div> + </div> + ); + + if (trackScroll) { + return ( + <Column icon='bell' heading={intl.formatMessage(messages.title)}> + <ScrollContainer scrollKey='notifications'> + {scrollableArea} + </ScrollContainer> + </Column> + ); + } else { + return ( + <Column icon='bell' heading={intl.formatMessage(messages.title)}> + {scrollableArea} + </Column> + ); + } + } + +}); + +export default connect(mapStateToProps)(injectIntl(Notifications)); diff --git a/app/assets/javascripts/components/features/reblogs/index.jsx b/app/assets/javascripts/components/features/reblogs/index.jsx index 5f22065f6..a1028870b 100644 --- a/app/assets/javascripts/components/features/reblogs/index.jsx +++ b/app/assets/javascripts/components/features/reblogs/index.jsx @@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import LoadingIndicator from '../../components/loading_indicator'; import { fetchReblogs } from '../../actions/interactions'; import { ScrollContainer } from 'react-router-scroll'; -import AccountContainer from '../followers/containers/account_container'; +import AccountContainer from '../../containers/account_container'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx index 553baf863..0a1528fe9 100644 --- a/app/assets/javascripts/components/features/status/index.jsx +++ b/app/assets/javascripts/components/features/status/index.jsx @@ -38,6 +38,9 @@ const makeMapStateToProps = () => { }; const Status = React.createClass({ + contextTypes: { + router: React.PropTypes.object + }, propTypes: { params: React.PropTypes.object.isRequired, @@ -64,7 +67,7 @@ const Status = React.createClass({ }, handleReplyClick (status) { - this.props.dispatch(replyCompose(status)); + this.props.dispatch(replyCompose(status, this.context.router)); }, handleReblogClick (status) { diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx index 8313d8826..868ebe00a 100644 --- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx +++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx @@ -31,7 +31,7 @@ const TabsBar = () => { <div style={outerStyle}> <Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> - <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/mentions'><i className='fa fa-fw fa-at' /> <FormattedMessage id='tabs_bar.mentions' defaultMessage='Mentions' /></Link> + <Link style={tabStyle} activeStyle={tabActiveStyle} to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /> <FormattedMessage id='tabs_bar.public' defaultMessage='Public' /></Link> </div> ); diff --git a/app/assets/javascripts/components/features/ui/containers/notifications_container.jsx b/app/assets/javascripts/components/features/ui/containers/notifications_container.jsx index eb12989e5..529ebf6c8 100644 --- a/app/assets/javascripts/components/features/ui/containers/notifications_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/notifications_container.jsx @@ -1,19 +1,19 @@ -import { connect } from 'react-redux'; -import { NotificationStack } from 'react-notification'; +import { connect } from 'react-redux'; +import { NotificationStack } from 'react-notification'; import { - dismissNotification, - clearNotifications -} from '../../../actions/notifications'; -import { getNotifications } from '../../../selectors'; + dismissAlert, + clearAlerts +} from '../../../actions/alerts'; +import { getAlerts } from '../../../selectors'; const mapStateToProps = (state, props) => ({ - notifications: getNotifications(state) + notifications: getAlerts(state) }); const mapDispatchToProps = (dispatch) => { return { - onDismiss: notifiction => { - dispatch(dismissNotification(notifiction)); + onDismiss: alert => { + dispatch(dismissAlert(alert)); } }; }; diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx index 655b1e2ee..9b5469bbd 100644 --- a/app/assets/javascripts/components/features/ui/index.jsx +++ b/app/assets/javascripts/components/features/ui/index.jsx @@ -1,13 +1,14 @@ -import ColumnsArea from './components/columns_area'; +import ColumnsArea from './components/columns_area'; import NotificationsContainer from './containers/notifications_container'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import LoadingBarContainer from './containers/loading_bar_container'; -import HomeTimeline from '../home_timeline'; -import MentionsTimeline from '../mentions_timeline'; -import Compose from '../compose'; -import MediaQuery from 'react-responsive'; -import TabsBar from './components/tabs_bar'; -import ModalContainer from './containers/modal_container'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import LoadingBarContainer from './containers/loading_bar_container'; +import HomeTimeline from '../home_timeline'; +import MentionsTimeline from '../mentions_timeline'; +import Compose from '../compose'; +import MediaQuery from 'react-responsive'; +import TabsBar from './components/tabs_bar'; +import ModalContainer from './containers/modal_container'; +import Notifications from '../notifications'; const UI = React.createClass({ @@ -26,11 +27,11 @@ const UI = React.createClass({ {this.props.children} </MediaQuery> - <MediaQuery minWidth={layoutBreakpoint}> + <MediaQuery minWidth={layoutBreakpoint + 1}> <ColumnsArea> <Compose /> <HomeTimeline trackScroll={false} /> - <MentionsTimeline trackScroll={false} /> + <Notifications trackScroll={false} /> {this.props.children} </ColumnsArea> </MediaQuery> diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx index e6f4a2491..85412635e 100644 --- a/app/assets/javascripts/components/locales/de.jsx +++ b/app/assets/javascripts/components/locales/de.jsx @@ -26,10 +26,12 @@ const en = { "column.home": "Home", "column.mentions": "Erwähnungen", "column.public": "Gesamtes Bekanntes Netz", + "column.notifications": "Mitteilungen", "tabs_bar.compose": "Schreiben", "tabs_bar.home": "Home", "tabs_bar.mentions": "Erwähnungen", "tabs_bar.public": "Gesamtes Netz", + "tabs_bar.notifications": "Mitteilungen", "compose_form.placeholder": "Worüber möchstest du schreiben?", "compose_form.publish": "Veröffentlichen", "navigation_bar.settings": "Einstellungen", @@ -42,7 +44,11 @@ const en = { "suggestions_box.who_to_follow": "Wem folgen", "suggestions_box.refresh": "Aktualisieren", "upload_button.label": "Media-Datei anfügen", - "upload_form.undo": "Entfernen" + "upload_form.undo": "Entfernen", + "notification.follow": "{name} folgt dir", + "notification.favourite": "{name} favorisierte deinen Status", + "notification.reblog": "{name} teilte deinen Status", + "notification.mention": "{name} erwähnte dich" }; export default en; diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index a28c84b03..b2c8390c1 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -27,10 +27,12 @@ const en = { "column.home": "Home", "column.mentions": "Mentions", "column.public": "Public", + "column.notifications": "Notifications", "tabs_bar.compose": "Compose", "tabs_bar.home": "Home", "tabs_bar.mentions": "Mentions", "tabs_bar.public": "Public", + "tabs_bar.notifications": "Notifications", "compose_form.placeholder": "What is on your mind?", "compose_form.publish": "Publish", "navigation_bar.settings": "Settings", @@ -43,7 +45,11 @@ const en = { "suggestions_box.who_to_follow": "Who to follow", "suggestions_box.refresh": "Refresh", "upload_button.label": "Add media", - "upload_form.undo": "Undo" + "upload_form.undo": "Undo", + "notification.follow": "{name} followed you", + "notification.favourite": "{name} favourited your status", + "notification.reblog": "{name} reblogged your status", + "notification.mention": "{name} mentioned you" }; export default en; diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx index c58c4bdc8..47377e5ae 100644 --- a/app/assets/javascripts/components/locales/es.jsx +++ b/app/assets/javascripts/components/locales/es.jsx @@ -27,10 +27,12 @@ const es = { "column.home": "Inicio", "column.mentions": "Menciones", "column.public": "Historia pública", + "column.notifications": "Notificaciones", "tabs_bar.compose": "Redactar", "tabs_bar.home": "Inicio", "tabs_bar.mentions": "Menciones", "tabs_bar.public": "Público", + "tabs_bar.notifications": "Notificaciones", "compose_form.placeholder": "¿En qué estás pensando?", "compose_form.publish": "Publicar", "navigation_bar.settings": "Ajustes", @@ -43,7 +45,11 @@ const es = { "suggestions_box.who_to_follow": "A quién seguir", "suggestions_box.refresh": "Refrescar", "upload_button.label": "Añadir medio", - "upload_form.undo": "Deshacer" + "upload_form.undo": "Deshacer", + "notification.follow": "{name} le esta ahora siguiendo", + "notification.favourite": "{name} marcó como favorito su estado", + "notification.reblog": "{name} volvió a publicar su estado", + "notification.mention": "Fue mencionado por {name}" }; export default es; diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx new file mode 100644 index 000000000..02b21f3cb --- /dev/null +++ b/app/assets/javascripts/components/locales/pt.jsx @@ -0,0 +1,49 @@ +const pt = { + "column_back_button.label": "Voltar", + "lightbox.close": "Fechar", + "loading_indicator.label": "Carregando...", + "status.mention": "Menção", + "status.delete": "Deletar", + "status.reply": "Responder", + "status.reblog": "Reblogar", + "status.favourite": "Favoritar", + "status.reblogged_by": "{name} reblogou", + "video_player.toggle_sound": "Alterar som", + "account.mention": "Menção", + "account.edit_profile": "Editar perfil", + "account.unblock": "Desbloquear", + "account.unfollow": "Unfollow", + "account.block": "Bloquear", + "account.follow": "Seguir", + "account.block": "Bloquear", + "account.posts": "Posts", + "account.follows": "Segue", + "account.followers": "Seguidoress", + "account.follows_you": "Segue você", + "getting_started.heading": "Primeiros passos", + "getting_started.about_addressing": "Podes seguir pessoas se sabes o nome de usuário deles e o domínio em que estão entrando um endereço similar a e-mail no campo no topo da barra lateral.", + "getting_started.about_shortcuts": "Se o usuário alvo está no mesmo domínio, só o nome funcionará. A mesma regra se aplica a mencionar pessoas nas postagens.", + "getting_started.about_developer": "O desenvolvedor desse projeto pode ser seguido em Gargron@mastodon.social", + "column.home": "Home", + "column.mentions": "Menções", + "column.public": "Público", + "tabs_bar.compose": "Compôr", + "tabs_bar.home": "Home", + "tabs_bar.mentions": "Menções", + "tabs_bar.public": "Público", + "compose_form.placeholder": "Que estás pensando?", + "compose_form.publish": "Publicar", + "navigation_bar.settings": "Configurações", + "navigation_bar.public_timeline": "Timeline Pública", + "navigation_bar.logout": "Logout", + "reply_indicator.cancel": "Cancelar", + "search.placeholder": "Busca", + "search.account": "Conta", + "search.hashtag": "Hashtag", + "suggestions_box.who_to_follow": "Quem seguir", + "suggestions_box.refresh": "Recarregar", + "upload_button.label": "Adicionar media", + "upload_form.undo": "Desfazer" +}; + +export default pt; diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx index 9d2aa19d0..fb161fc4c 100644 --- a/app/assets/javascripts/components/middleware/errors.jsx +++ b/app/assets/javascripts/components/middleware/errors.jsx @@ -1,4 +1,4 @@ -import { showNotification } from '../actions/notifications'; +import { showAlert } from '../actions/alerts'; const defaultFailSuffix = 'FAIL'; @@ -18,10 +18,10 @@ export default function errorsMiddleware() { message = data.error; } - dispatch(showNotification(title, message)); + dispatch(showAlert(title, message)); } else { console.error(action.error); - dispatch(showNotification('Oops!', 'An unexpected error occurred. Inspect the console for more details')); + dispatch(showAlert('Oops!', 'An unexpected error occurred. Inspect the console for more details')); } } } diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index c0ea961b7..68247a98c 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -28,6 +28,11 @@ import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; import Immutable from 'immutable'; const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account)); @@ -64,6 +69,7 @@ export default function accounts(state = initialState, action) { switch(action.type) { case ACCOUNT_SET_SELF: case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: return normalizeAccount(state, action.account); case SUGGESTIONS_FETCH_SUCCESS: case FOLLOWERS_FETCH_SUCCESS: @@ -74,6 +80,8 @@ export default function accounts(state = initialState, action) { case FAVOURITES_FETCH_SUCCESS: case COMPOSE_SUGGESTIONS_READY: case SEARCH_SUGGESTIONS_READY: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: return normalizeAccounts(state, action.accounts); case TIMELINE_REFRESH_SUCCESS: case TIMELINE_EXPAND_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/alerts.jsx b/app/assets/javascripts/components/reducers/alerts.jsx new file mode 100644 index 000000000..42987f649 --- /dev/null +++ b/app/assets/javascripts/components/reducers/alerts.jsx @@ -0,0 +1,25 @@ +import { + ALERT_SHOW, + ALERT_DISMISS, + ALERT_CLEAR +} from '../actions/alerts'; +import Immutable from 'immutable'; + +const initialState = Immutable.List([]); + +export default function alerts(state = initialState, action) { + switch(action.type) { + case ALERT_SHOW: + return state.push(Immutable.Map({ + key: state.size > 0 ? state.last().get('key') + 1 : 0, + title: action.title, + message: action.message + })); + case ALERT_DISMISS: + return state.filterNot(item => item.get('key') === action.alert.key); + case ALERT_CLEAR: + return state.clear(); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx index 3adff36a3..e6e86d4f5 100644 --- a/app/assets/javascripts/components/reducers/compose.jsx +++ b/app/assets/javascripts/components/reducers/compose.jsx @@ -1,4 +1,6 @@ import { + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, COMPOSE_CHANGE, COMPOSE_REPLY, COMPOSE_REPLY_CANCEL, @@ -20,6 +22,7 @@ import { ACCOUNT_SET_SELF } from '../actions/accounts'; import Immutable from 'immutable'; const initialState = Immutable.Map({ + mounted: false, text: '', in_reply_to: null, is_submitting: false, @@ -80,6 +83,10 @@ const insertSuggestion = (state, position, completion) => { export default function compose(state = initialState, action) { switch(action.type) { + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state.set('mounted', false); case COMPOSE_CHANGE: return state.set('text', action.text); case COMPOSE_REPLY: diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index 1e015cf74..aea9239f8 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -1,26 +1,28 @@ -import { combineReducers } from 'redux-immutable'; -import timelines from './timelines'; -import meta from './meta'; -import compose from './compose'; -import notifications from './notifications'; +import { combineReducers } from 'redux-immutable'; +import timelines from './timelines'; +import meta from './meta'; +import compose from './compose'; +import alerts from './alerts'; import { loadingBarReducer } from 'react-redux-loading-bar'; -import modal from './modal'; -import user_lists from './user_lists'; -import accounts from './accounts'; -import statuses from './statuses'; -import relationships from './relationships'; -import search from './search'; +import modal from './modal'; +import user_lists from './user_lists'; +import accounts from './accounts'; +import statuses from './statuses'; +import relationships from './relationships'; +import search from './search'; +import notifications from './notifications'; export default combineReducers({ timelines, meta, compose, - notifications, + alerts, loadingBar: loadingBarReducer, modal, user_lists, accounts, statuses, relationships, - search + search, + notifications }); diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx index efe8d9739..0e67e732a 100644 --- a/app/assets/javascripts/components/reducers/notifications.jsx +++ b/app/assets/javascripts/components/reducers/notifications.jsx @@ -1,24 +1,56 @@ import { - NOTIFICATION_SHOW, - NOTIFICATION_DISMISS, - NOTIFICATION_CLEAR + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS } from '../actions/notifications'; import Immutable from 'immutable'; -const initialState = Immutable.List([]); +const initialState = Immutable.Map({ + items: Immutable.List(), + next: null, + loaded: false +}); + +const notificationToMap = notification => Immutable.Map({ + id: notification.id, + type: notification.type, + account: notification.account.id, + status: notification.status ? notification.status.id : null +}); + +const normalizeNotification = (state, notification) => { + return state.update('items', list => list.unshift(notificationToMap(notification))); +}; + +const normalizeNotifications = (state, notifications, next) => { + let items = Immutable.List(); + const loaded = state.get('loaded'); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(n)); + }); + + return state.update('items', list => loaded ? list.unshift(...items) : list.push(...items)).set('next', next).set('loaded', true); +}; + +const appendNormalizedNotifications = (state, notifications, next) => { + let items = Immutable.List(); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(n)); + }); + + return state.update('items', list => list.push(...items)).set('next', next); +}; export default function notifications(state = initialState, action) { switch(action.type) { - case NOTIFICATION_SHOW: - return state.push(Immutable.Map({ - key: state.size > 0 ? state.last().get('key') + 1 : 0, - title: action.title, - message: action.message - })); - case NOTIFICATION_DISMISS: - return state.filterNot(item => item.get('key') === action.notification.key); - case NOTIFICATION_CLEAR: - return state.clear(); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_REFRESH_SUCCESS: + return normalizeNotifications(state, action.notifications, action.next); + case NOTIFICATIONS_EXPAND_SUCCESS: + return appendNormalizedNotifications(state, action.notifications, action.next); default: return state; } diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index 69c0e6193..2a24a75e4 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -18,9 +18,18 @@ import { ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS } from '../actions/accounts'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; import Immutable from 'immutable'; const normalizeStatus = (state, status) => { + if (!status) { + return state; + } + status.account = status.account.id; if (status.reblog && status.reblog.id) { @@ -53,6 +62,7 @@ export default function statuses(state = initialState, action) { switch(action.type) { case TIMELINE_UPDATE: case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: return normalizeStatus(state, action.status); case REBLOG_SUCCESS: case UNREBLOG_SUCCESS: @@ -64,6 +74,8 @@ export default function statuses(state = initialState, action) { case ACCOUNT_TIMELINE_FETCH_SUCCESS: case ACCOUNT_TIMELINE_EXPAND_SUCCESS: case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: return normalizeStatuses(state, action.statuses); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx index 33b179cb8..20debe604 100644 --- a/app/assets/javascripts/components/selectors/index.jsx +++ b/app/assets/javascripts/components/selectors/index.jsx @@ -1,5 +1,5 @@ import { createSelector } from 'reselect' -import Immutable from 'immutable'; +import Immutable from 'immutable'; const getStatuses = state => state.get('statuses'); const getAccounts = state => state.get('accounts'); @@ -50,9 +50,9 @@ const assembleStatus = (id, statuses, accounts) => { return status.set('reblog', reblog).set('account', accounts.get(status.get('account'))); }; -const getNotificationsBase = state => state.get('notifications'); +const getAlertsBase = state => state.get('alerts'); -export const getNotifications = createSelector([getNotificationsBase], (base) => { +export const getAlerts = createSelector([getAlertsBase], (base) => { let arr = []; base.forEach(item => { @@ -66,3 +66,12 @@ export const getNotifications = createSelector([getNotificationsBase], (base) => return arr; }); + +export const makeGetNotification = () => { + return createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]) + ], (base, account) => { + return base.set('account', account); + }); +}; diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index ba091c15e..adf0db990 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -219,6 +219,30 @@ } } +.muted { + .status__content p, .status__content a { + color: #616b86; + } + + .status__display-name strong { + color: #616b86; + } + + .status__avatar { + opacity: 0.5; + } +} + +.notification__display-name { + color: inherit; + text-decoration: none; + + &:hover { + color: #fff; + text-decoration: underline; + } +} + .status__relative-time, .detailed-status__datetime { &:hover { text-decoration: underline; diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb index 69e069212..f9154bd7f 100644 --- a/app/channels/application_cable/channel.rb +++ b/app/channels/application_cable/channel.rb @@ -10,7 +10,7 @@ module ApplicationCable return [nil, message] if message['type'] == 'delete' status = Status.find_by(id: message['id']) - message['message'] = FeedManager.instance.inline_render(current_user.account, status) + message['message'] = FeedManager.instance.inline_render(current_user.account, 'api/v1/statuses/show', status) [status, message] end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb new file mode 100644 index 000000000..509471f61 --- /dev/null +++ b/app/controllers/api/v1/notifications_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::NotificationsController < ApiController + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + + respond_to :json + + def index + @notifications = Notification.where(account: current_account).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) + + next_path = api_v1_notifications_url(max_id: @notifications.last.id) if @notifications.size == 20 + prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty? + + set_pagination_headers(next_path, prev_path) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f9aeb127a..3a4c95db4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -13,7 +13,7 @@ class ApplicationController < ActionController::Base rescue_from ActiveRecord::RecordNotFound, with: :not_found before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? - before_action :set_locale, if: 'user_signed_in?' + before_action :set_locale def raise_not_found raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}" @@ -26,7 +26,7 @@ class ApplicationController < ActionController::Base end def set_locale - I18n.locale = current_user.locale || I18n.default_locale + I18n.locale = current_user.try(:locale) || I18n.default_locale rescue I18n::InvalidLocale I18n.locale = I18n.default_locale end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index b808d7a0f..c8512476d 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -26,7 +26,7 @@ class FeedManager def push(timeline_type, account, status) redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id) trim(timeline_type, account.id) - broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, status)) + broadcast(account.id, type: 'update', timeline: timeline_type, message: inline_render(account, 'api/v1/statuses/show', status)) end def broadcast(timeline_id, options = {}) @@ -39,7 +39,7 @@ class FeedManager redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}") end - def inline_render(target_account, status) + def inline_render(target_account, template, object) rabl_scope = Class.new do include RoutingHelper @@ -56,7 +56,7 @@ class FeedManager end end - Rabl::Renderer.new('api/v1/statuses/show', status, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render + Rabl::Renderer.new(template, object, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render end private diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index cf5ad3f92..7b2cac7f3 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -3,46 +3,38 @@ class NotificationMailer < ApplicationMailer helper StreamEntriesHelper - def mention(mentioned_account, status) - @me = mentioned_account - @status = status - - return unless @me.user.settings(:notification_emails).mention + def mention(recipient, notification) + @me = recipient + @status = notification.target_status I18n.with_locale(@me.user.locale || I18n.default_locale) do mail to: @me.user.email, subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct) end end - def follow(followed_account, follower) - @me = followed_account - @account = follower - - return unless @me.user.settings(:notification_emails).follow + def follow(recipient, notification) + @me = recipient + @account = notification.from_account I18n.with_locale(@me.user.locale || I18n.default_locale) do mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) end end - def favourite(target_status, from_account) - @me = target_status.account - @account = from_account - @status = target_status - - return unless @me.user.settings(:notification_emails).favourite + def favourite(recipient, notification) + @me = recipient + @account = notification.from_account + @status = notification.target_status I18n.with_locale(@me.user.locale || I18n.default_locale) do mail to: @me.user.email, subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct) end end - def reblog(target_status, from_account) - @me = target_status.account - @account = from_account - @status = target_status - - return unless @me.user.settings(:notification_emails).reblog + def reblog(recipient, notification) + @me = recipient + @account = notification.from_account + @status = notification.target_status I18n.with_locale(@me.user.locale || I18n.default_locale) do mail to: @me.user.email, subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct) diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 000000000..66aefcb74 --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Notification < ApplicationRecord + include Paginable + + belongs_to :account + belongs_to :activity, polymorphic: true + + belongs_to :mention, foreign_type: 'Mention', foreign_key: 'activity_id' + belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id' + belongs_to :follow, foreign_type: 'Follow', foreign_key: 'activity_id' + belongs_to :favourite, foreign_type: 'Favourite', foreign_key: 'activity_id' + + STATUS_INCLUDES = [:account, :media_attachments, mentions: :account, reblog: [:account, mentions: :account]].freeze + + scope :with_includes, -> { includes(status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account) } + + def type + case activity_type + when 'Status' + :reblog + else + activity_type.downcase.to_sym + end + end + + def from_account + case type + when :mention + activity.status.account + when :follow, :favourite, :reblog + activity.account + end + end + + def target_status + case type + when :reblog + activity.reblog + when :favourite, :mention + activity.status + end + end +end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 9c6f12478..781b03b40 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -10,7 +10,7 @@ class FavouriteService < BaseService HubPingWorker.perform_async(account.id) if status.local? - NotificationMailer.favourite(status, account).deliver_later unless status.account.blocking?(account) + NotifyService.new.call(status.account, favourite) else NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id) end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 3b97840cb..cdae254bf 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -12,7 +12,7 @@ class FollowService < BaseService follow = source_account.follow!(target_account) if target_account.local? - NotificationMailer.follow(target_account, source_account).deliver_later unless target_account.blocking?(source_account) + NotifyService.new.call(target_account, follow) else subscribe_service.call(target_account) NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb new file mode 100644 index 000000000..c0f1d4c53 --- /dev/null +++ b/app/services/notify_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class NotifyService < BaseService + def call(recipient, activity) + @recipient = recipient + @activity = activity + @notification = Notification.new(account: @recipient, activity: @activity) + + return if blocked? + + create_notification + send_email if email_enabled? + end + + private + + def blocked_mention? + FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient) + end + + def blocked_favourite? + false + end + + def blocked_follow? + false + end + + def blocked_reblog? + false + end + + def blocked? + blocked = false + blocked ||= @recipient.id == @notification.from_account.id + blocked ||= @recipient.blocking?(@notification.from_account) + blocked ||= send("blocked_#{@notification.type}?") + blocked + end + + def create_notification + @notification.save! + FeedManager.instance.broadcast(@recipient.id, type: 'notification', message: FeedManager.instance.inline_render(@recipient, 'api/v1/notifications/show', @notification)) + end + + def send_email + NotificationMailer.send(@notification.type, @recipient, @notification).deliver_later + end + + def email_enabled? + @recipient.user.settings(:notification_emails).send(@notification.type) + end +end diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 8daea1675..8daafd444 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -60,6 +60,8 @@ class ProcessFeedService < BaseService end status.save! + + NotifyService.new.call(status.reblog.account, status) if status.reblog? Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution" DistributionWorker.perform_async(status.id) status @@ -150,12 +152,10 @@ class ProcessFeedService < BaseService next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id) - if mentioned_account.local? - # Send notifications - NotificationMailer.mention(mentioned_account, parent).deliver_later unless mentioned_account.blocking?(parent.account) - end + mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent) - mentioned_account.mentions.where(status: parent).first_or_create(status: parent) + # Notify local user + NotifyService.new.call(mentioned_account, mention) if mentioned_account.local? # So we can skip duplicate mentions processed_account_ids << mentioned_account.id diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index ecd3c2b2c..e7bb3c73b 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -65,8 +65,8 @@ class ProcessInteractionService < BaseService end def follow!(account, target_account) - account.follow!(target_account) - NotificationMailer.follow(target_account, account).deliver_later unless target_account.blocking?(account) + follow = account.follow!(target_account) + NotifyService.new.call(target_account, follow) end def unfollow!(account, target_account) @@ -83,8 +83,8 @@ class ProcessInteractionService < BaseService def favourite!(xml, from_account) current_status = status(xml) - current_status.favourites.where(account: from_account).first_or_create!(account: from_account) - NotificationMailer.favourite(current_status, from_account).deliver_later unless current_status.account.blocking?(from_account) + favourite = current_status.favourites.where(account: from_account).first_or_create!(account: from_account) + NotifyService.new.call(current_status.account, favourite) end def add_post!(body, account) diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index fd5a02ffe..98e48299e 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -29,7 +29,7 @@ class ProcessMentionsService < BaseService mentioned_account = mention.account if mentioned_account.local? - NotificationMailer.mention(mentioned_account, status).deliver_later unless mentioned_account.blocking?(status.account) + NotifyService.new.call(mentioned_account, mention) else NotificationWorker.perform_async(status.stream_entry.id, mentioned_account.id) end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 884d911a4..6543d4ae7 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -11,7 +11,7 @@ class ReblogService < BaseService HubPingWorker.perform_async(account.id) if reblogged_status.local? - NotificationMailer.reblog(reblogged_status, account).deliver_later unless reblogged_status.account.blocking?(account) + NotifyService.new.call(reblogged_status.account, reblog) else NotificationWorker.perform_async(reblog.stream_entry.id, reblogged_status.account_id) end diff --git a/app/views/api/v1/notifications/index.rabl b/app/views/api/v1/notifications/index.rabl new file mode 100644 index 000000000..6abc3da36 --- /dev/null +++ b/app/views/api/v1/notifications/index.rabl @@ -0,0 +1,2 @@ +collection @notifications +extends 'api/v1/notifications/show' diff --git a/app/views/api/v1/notifications/show.rabl b/app/views/api/v1/notifications/show.rabl new file mode 100644 index 000000000..fe2218ed7 --- /dev/null +++ b/app/views/api/v1/notifications/show.rabl @@ -0,0 +1,11 @@ +object @notification + +attributes :id, :type + +child from_account: :account do + extends 'api/v1/accounts/show' +end + +node(:status, if: lambda { |n| [:favourite, :reblog, :mention].include?(n.type) }) do |n| + partial 'api/v1/statuses/show', object: n.target_status +end diff --git a/config/application.rb b/config/application.rb index d62c7e83e..c53d78a4c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -40,7 +40,6 @@ module Mastodon config.middleware.use Rack::Attack config.middleware.use Rack::Deflater - config.browserify_rails.source_map_environments += %w(development production) config.browserify_rails.commandline_options = '--transform [ babelify --presets [ es2015 react ] ] --extension=".jsx"' config.to_prepare do diff --git a/config/routes.rb b/config/routes.rb index 176a4ccc8..00185c5e8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,6 +74,8 @@ Rails.application.routes.draw do resources :media, only: [:create] resources :apps, only: [:create] + resources :notifications, only: [:index] + resources :accounts, only: [:show] do collection do get :relationships diff --git a/db/migrate/20161119211120_create_notifications.rb b/db/migrate/20161119211120_create_notifications.rb new file mode 100644 index 000000000..e6bf1d66e --- /dev/null +++ b/db/migrate/20161119211120_create_notifications.rb @@ -0,0 +1,14 @@ +class CreateNotifications < ActiveRecord::Migration[5.0] + def change + create_table :notifications do |t| + t.integer :account_id + t.integer :activity_id + t.string :activity_type + + t.timestamps + end + + add_index :notifications, :account_id + add_index :notifications, [:account_id, :activity_id, :activity_type], unique: true, name: 'account_activity' + end +end diff --git a/db/schema.rb b/db/schema.rb index 84f75c278..20bfb36a8 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: 20161116162355) do +ActiveRecord::Schema.define(version: 20161119211120) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -96,6 +96,16 @@ ActiveRecord::Schema.define(version: 20161116162355) do t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree end + create_table "notifications", force: :cascade do |t| + t.integer "account_id" + t.integer "activity_id" + t.string "activity_type" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "activity_id", "activity_type"], name: "account_activity", unique: true, using: :btree + t.index ["account_id"], name: "index_notifications_on_account_id", using: :btree + end + create_table "oauth_access_grants", force: :cascade do |t| t.integer "resource_owner_id", null: false t.integer "application_id", null: false diff --git a/spec/fabricators/notification_fabricator.rb b/spec/fabricators/notification_fabricator.rb new file mode 100644 index 000000000..08e984904 --- /dev/null +++ b/spec/fabricators/notification_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:notification) do + activity_id 1 + activity_type "MyString" +end diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index d7a956b75..d4baca5aa 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -7,7 +7,8 @@ RSpec.describe NotificationMailer, type: :mailer do let(:own_status) { Fabricate(:status, account: receiver.account) } describe "mention" do - let(:mail) { NotificationMailer.mention(receiver.account, foreign_status) } + let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) } + let(:mail) { NotificationMailer.mention(receiver.account, Notification.create!(account: receiver.account, activity: mention)) } it "renders the headers" do expect(mail.subject).to eq("You were mentioned by bob") @@ -20,7 +21,8 @@ RSpec.describe NotificationMailer, type: :mailer do end describe "follow" do - let(:mail) { NotificationMailer.follow(receiver.account, sender) } + let(:follow) { sender.follow!(receiver.account) } + let(:mail) { NotificationMailer.follow(receiver.account, Notification.create!(account: receiver.account, activity: follow)) } it "renders the headers" do expect(mail.subject).to eq("bob is now following you") @@ -33,7 +35,8 @@ RSpec.describe NotificationMailer, type: :mailer do end describe "favourite" do - let(:mail) { NotificationMailer.favourite(own_status, sender) } + let(:favourite) { Favourite.create!(account: sender, status: own_status) } + let(:mail) { NotificationMailer.favourite(own_status.account, Notification.create!(account: receiver.account, activity: favourite)) } it "renders the headers" do expect(mail.subject).to eq("bob favourited your status") @@ -46,7 +49,8 @@ RSpec.describe NotificationMailer, type: :mailer do end describe "reblog" do - let(:mail) { NotificationMailer.reblog(own_status, sender) } + let(:reblog) { Status.create!(account: sender, reblog: own_status) } + let(:mail) { NotificationMailer.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) } it "renders the headers" do expect(mail.subject).to eq("bob reblogged your status") diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb new file mode 100644 index 000000000..97e8095cd --- /dev/null +++ b/spec/models/notification_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe Notification, type: :model do + describe '#from_account' do + pending + end + + describe '#type' do + it 'returns :reblog for a Status' do + notification = Notification.new(activity: Status.new) + expect(notification.type).to eq :reblog + end + + it 'returns :mention for a Mention' do + notification = Notification.new(activity: Mention.new) + expect(notification.type).to eq :mention + end + + it 'returns :favourite for a Favourite' do + notification = Notification.new(activity: Favourite.new) + expect(notification.type).to eq :favourite + end + + it 'returns :follow for a Follow' do + notification = Notification.new(activity: Follow.new) + expect(notification.type).to eq :follow + end + end +end |