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/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx index 79457eba6..7863d9683 100644 --- a/app/assets/javascripts/components/actions/notifications.jsx +++ b/app/assets/javascripts/components/actions/notifications.jsx @@ -1,24 +1,124 @@ -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'; -export function dismissNotification(notification) { - return { - type: NOTIFICATION_DISMISS, - notification: 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) { + return dispatch => { + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + account: notification.account, + status: notification.status + }); + + fetchRelatedRelationships(dispatch, [notification]); }; }; -export function clearNotifications() { - return { - type: NOTIFICATION_CLEAR +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 showNotification(title, message) { +export function refreshNotificationsRequest() { return { - type: NOTIFICATION_SHOW, - title: title, - message: message + 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: NOTIFICATIONS_EXPAND_REQUEST + }; +}; + +export function expandNotificationsSuccess(notifications, next) { + return { + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status), + next + }; +}; + +export function expandNotificationsFail(error) { + return { + 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 similarity index 93% rename from app/assets/javascripts/components/features/followers/components/account.jsx rename to 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 ( -
+
-
+
diff --git a/app/assets/javascripts/components/features/followers/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx similarity index 72% rename from app/assets/javascripts/components/features/followers/containers/account_container.jsx rename to 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..cf77c169d 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'; @@ -75,11 +77,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))); } } }); } + + // Desktop notifications + if (Notification.permission === 'default') { + Notification.requestPermission(); + } }, componentWillUnmount () { @@ -103,6 +112,8 @@ const Mastodon = React.createClass({ + + 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/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 ( +
+
+ +
+ ); + }, + + renderMention (notification) { + return ; + }, + + renderFavourite (notification, link) { + return ( +
+
+ +
+ ); + }, + + renderReblog (notification, link) { + return ( +
+
+ +
+ ); + }, + + 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 = {displayName}; + + 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..9c8b07dea --- /dev/null +++ b/app/assets/javascripts/components/features/notifications/index.jsx @@ -0,0 +1,61 @@ +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 + }, + + 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 } = this.props; + + return ( + + +
+
+ {notifications.map(item => )} +
+
+
+
+ ); + } + +}); + +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/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/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/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/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/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/db/migrate/20161119211120_create_notifications.rb b/db/migrate/20161119211120_create_notifications.rb index dcbe51767..e6bf1d66e 100644 --- a/db/migrate/20161119211120_create_notifications.rb +++ b/db/migrate/20161119211120_create_notifications.rb @@ -9,5 +9,6 @@ class CreateNotifications < ActiveRecord::Migration[5.0] 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 2bc9e5ee8..20bfb36a8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -102,6 +102,7 @@ ActiveRecord::Schema.define(version: 20161119211120) do 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