diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features/notifications')
14 files changed, 1144 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js new file mode 100644 index 000000000..ee77cfb8e --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +export default class ClearColumnButton extends React.Component { + + static propTypes = { + onClick: PropTypes.func.isRequired, + }; + + render () { + return ( + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><Icon id='eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js new file mode 100644 index 000000000..e4d5d0eda --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -0,0 +1,119 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import ClearColumnButton from './clear_column_button'; +import SettingToggle from './setting_toggle'; + +export default class ColumnSettings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + }; + + onPushChange = (path, checked) => { + this.props.onChange(['push', ...path], checked); + } + + render () { + const { settings, pushSettings, onChange, onClear } = this.props; + + const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; + const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; + const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; + const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; + const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; + + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; + const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />; + + return ( + <div> + <div className='column-settings__row'> + <ClearColumnButton onClick={onClear} /> + </div> + + <div role='group' aria-labelledby='notifications-filter-bar'> + <span id='notifications-filter-bar' className='column-settings__section'> + <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> + </span> + <div className='column-settings__row'> + <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} /> + <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-follow'> + <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-follow-request'> + <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-favourite'> + <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-mention'> + <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-reblog'> + <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + + <div role='group' aria-labelledby='notifications-poll'> + <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> + + <div className='column-settings__row'> + <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js new file mode 100644 index 000000000..6118305d6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const tooltips = defineMessages({ + mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, + favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' }, + boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, + polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, + follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, +}); + +export default @injectIntl +class FilterBar extends React.PureComponent { + + static propTypes = { + selectFilter: PropTypes.func.isRequired, + selectedFilter: PropTypes.string.isRequired, + advancedMode: PropTypes.bool.isRequired, + intl: PropTypes.object.isRequired, + }; + + onClick (notificationType) { + return () => this.props.selectFilter(notificationType); + } + + render () { + const { selectedFilter, advancedMode, intl } = this.props; + const renderedElement = !advancedMode ? ( + <div className='notification__filter-bar'> + <button + className={selectedFilter === 'all' ? 'active' : ''} + onClick={this.onClick('all')} + > + <FormattedMessage + id='notifications.filter.all' + defaultMessage='All' + /> + </button> + <button + className={selectedFilter === 'mention' ? 'active' : ''} + onClick={this.onClick('mention')} + > + <FormattedMessage + id='notifications.filter.mentions' + defaultMessage='Mentions' + /> + </button> + </div> + ) : ( + <div className='notification__filter-bar'> + <button + className={selectedFilter === 'all' ? 'active' : ''} + onClick={this.onClick('all')} + > + <FormattedMessage + id='notifications.filter.all' + defaultMessage='All' + /> + </button> + <button + className={selectedFilter === 'mention' ? 'active' : ''} + onClick={this.onClick('mention')} + title={intl.formatMessage(tooltips.mentions)} + > + <Icon id='reply-all' fixedWidth /> + </button> + <button + className={selectedFilter === 'favourite' ? 'active' : ''} + onClick={this.onClick('favourite')} + title={intl.formatMessage(tooltips.favourites)} + > + <Icon id='star' fixedWidth /> + </button> + <button + className={selectedFilter === 'reblog' ? 'active' : ''} + onClick={this.onClick('reblog')} + title={intl.formatMessage(tooltips.boosts)} + > + <Icon id='retweet' fixedWidth /> + </button> + <button + className={selectedFilter === 'poll' ? 'active' : ''} + onClick={this.onClick('poll')} + title={intl.formatMessage(tooltips.polls)} + > + <Icon id='tasks' fixedWidth /> + </button> + <button + className={selectedFilter === 'follow' ? 'active' : ''} + onClick={this.onClick('follow')} + title={intl.formatMessage(tooltips.follows)} + > + <Icon id='user-plus' fixedWidth /> + </button> + </div> + ); + return renderedElement; + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js new file mode 100644 index 000000000..2b71f3107 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js @@ -0,0 +1,99 @@ +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; + +// Our imports. +import Permalink from 'flavours/glitch/components/permalink'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import Icon from 'flavours/glitch/components/icon'; + +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + hidden: PropTypes.bool, + id: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + notification: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + this.handleOpenProfile(); + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { account, notification, hidden } = this.props; + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/accounts/${account.get('id')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + // Renders. + return ( + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-follow focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon fixedWidth id='user-plus' /> + </div> + + <FormattedMessage + id='notification.follow' + defaultMessage='{name} followed you' + values={{ name: link }} + /> + </div> + + <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} /> + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js new file mode 100644 index 000000000..d73dac434 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js @@ -0,0 +1,130 @@ +import React, { Fragment } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import Permalink from 'flavours/glitch/components/permalink'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import NotificationOverlayContainer from '../containers/overlay_container'; +import { HotKeys } from 'react-hotkeys'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +export default @injectIntl +class FollowRequest extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + notification: ImmutablePropTypes.map.isRequired, + }; + + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + this.handleOpenProfile(); + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + + render () { + const { intl, hidden, account, onAuthorize, onReject, notification } = this.props; + + if (!account) { + return <div />; + } + + if (hidden) { + return ( + <Fragment> + {account.get('display_name')} + {account.get('username')} + </Fragment> + ); + } + + // Links to the display name. + const displayName = account.get('display_name_html') || account.get('username'); + const link = ( + <bdi><Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/accounts/${account.get('id')}`} + dangerouslySetInnerHTML={{ __html: displayName }} + /></bdi> + ); + + return ( + <HotKeys handlers={this.getHandlers()}> + <div className='notification notification-follow-request focusable' tabIndex='0'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <Icon id='user' fixedWidth /> + </div> + + <FormattedMessage + id='notification.follow_request' + defaultMessage='{name} has requested to follow you' + values={{ name: link }} + /> + </div> + + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__relationship'> + <IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /> + <IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /> + </div> + </div> + </div> + + <NotificationOverlayContainer notification={notification} /> + </div> + </HotKeys> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js new file mode 100644 index 000000000..62fc28386 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -0,0 +1,150 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Our imports, +import StatusContainer from 'flavours/glitch/containers/status_container'; +import NotificationFollow from './follow'; +import NotificationFollowRequestContainer from '../containers/follow_request_container'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, + onUnmount: PropTypes.func, + }; + + render () { + const { + hidden, + notification, + onMoveDown, + onMoveUp, + onMention, + getScrollPosition, + updateScrollBottom, + } = this.props; + + switch(notification.get('type')) { + case 'follow': + return ( + <NotificationFollow + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + /> + ); + case 'follow_request': + return ( + <NotificationFollowRequestContainer + hidden={hidden} + id={notification.get('id')} + account={notification.get('account')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + /> + ); + case 'mention': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + contextType='notifications' + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + case 'favourite': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='favourite' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + case 'reblog': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='reblog' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + case 'poll': + return ( + <StatusContainer + containerId={notification.get('id')} + hidden={hidden} + id={notification.get('status')} + account={notification.get('account')} + prepend='poll' + muted + notification={notification} + onMoveDown={onMoveDown} + onMoveUp={onMoveUp} + onMention={onMention} + getScrollPosition={getScrollPosition} + updateScrollBottom={updateScrollBottom} + cachedMediaWidth={this.props.cachedMediaWidth} + cacheMediaWidth={this.props.cacheMediaWidth} + onUnmount={this.props.onUnmount} + withDismiss + /> + ); + default: + return null; + } + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/overlay.js b/app/javascript/flavours/glitch/features/notifications/components/overlay.js new file mode 100644 index 000000000..f3ccafc06 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.js @@ -0,0 +1,58 @@ +/** + * Notification overlay + */ + + +// Package imports. +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; +import Icon from 'flavours/glitch/components/icon'; + +const messages = defineMessages({ + markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, +}); + +export default @injectIntl +class NotificationOverlay extends ImmutablePureComponent { + + static propTypes = { + notification : ImmutablePropTypes.map.isRequired, + onMarkForDelete : PropTypes.func.isRequired, + show : PropTypes.bool.isRequired, + intl : PropTypes.object.isRequired, + }; + + onToggleMark = () => { + const mark = !this.props.notification.get('markedForDelete'); + const id = this.props.notification.get('id'); + this.props.onMarkForDelete(id, mark); + } + + render () { + const { notification, show, intl } = this.props; + + const active = notification.get('markedForDelete'); + const label = intl.formatMessage(messages.markForDeletion); + + return show ? ( + <div + aria-label={label} + role='checkbox' + aria-checked={active} + tabIndex={0} + className={`notification__dismiss-overlay ${active ? 'active' : ''}`} + onClick={this.onToggleMark} + > + <div className='wrappy'> + <div className='ckbox' aria-hidden='true' title={label}> + {active ? (<Icon id='check' />) : ''} + </div> + </div> + </div> + ) : null; + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js new file mode 100644 index 000000000..0264b6815 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +export default class SettingToggle extends React.PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingPath: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + meta: PropTypes.node, + onChange: PropTypes.func.isRequired, + defaultValue: PropTypes.bool, + } + + onChange = ({ target }) => { + this.props.onChange(this.props.settingPath, target.checked); + } + + render () { + const { prefix, settings, settingPath, label, meta, defaultValue } = this.props; + const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); + + return ( + <div className='setting-toggle'> + <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <label htmlFor={id} className='setting-toggle__label'>{label}</label> + {meta && <span className='setting-meta__label'>{meta}</span>} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js new file mode 100644 index 000000000..4b863712a --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js @@ -0,0 +1,43 @@ +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { setFilter } from 'flavours/glitch/actions/notifications'; +import { clearNotifications } from 'flavours/glitch/actions/notifications'; +import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, + clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, +}); + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange (path, checked) { + if (path[0] === 'push') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else if (path[0] === 'quickFilter') { + dispatch(changeSetting(['notifications', ...path], checked)); + dispatch(setFilter('all')); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } + }, + + onClear () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()), + })); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js new file mode 100644 index 000000000..4d495c290 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import FilterBar from '../components/filter_bar'; +import { setFilter } from '../../../actions/notifications'; + +const makeMapStateToProps = state => ({ + selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']), + advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']), +}); + +const mapDispatchToProps = (dispatch) => ({ + selectFilter (newActiveFilter) { + dispatch(setFilter(newActiveFilter)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js new file mode 100644 index 000000000..82357adfb --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import FollowRequest from '../components/follow_request'; +import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts'; + +const mapDispatchToProps = (dispatch, { account }) => ({ + onAuthorize () { + dispatch(authorizeFollowRequest(account.get('id'))); + }, + + onReject () { + dispatch(rejectFollowRequest(account.get('id'))); + }, +}); + +export default connect(null, mapDispatchToProps)(FollowRequest); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js new file mode 100644 index 000000000..be007f30b --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js @@ -0,0 +1,26 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import { makeGetNotification } from 'flavours/glitch/selectors'; +import Notification from '../components/notification'; +import { mentionCompose } from 'flavours/glitch/actions/compose'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId), + notifCleaning: state.getIn(['notifications', 'cleaningMode']), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + onMention: (account, router) => { + dispatch(mentionCompose(account, router)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js new file mode 100644 index 000000000..ee2d19814 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js @@ -0,0 +1,18 @@ +// Package imports. +import { connect } from 'react-redux'; + +// Our imports. +import NotificationOverlay from '../components/overlay'; +import { markNotificationForDelete } from 'flavours/glitch/actions/notifications'; + +const mapDispatchToProps = dispatch => ({ + onMarkForDelete(id, yes) { + dispatch(markNotificationForDelete(id, yes)); + }, +}); + +const mapStateToProps = state => ({ + show: state.getIn(['notifications', 'cleaningMode']), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay); diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js new file mode 100644 index 000000000..26710feff --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -0,0 +1,314 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'flavours/glitch/components/column'; +import ColumnHeader from 'flavours/glitch/components/column_header'; +import { + enterNotificationClearingMode, + expandNotifications, + scrollTopNotifications, + mountNotifications, + unmountNotifications, + loadPending, +} from 'flavours/glitch/actions/notifications'; +import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import NotificationContainer from './containers/notification_container'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import FilterBarContainer from './containers/filter_bar_container'; +import { createSelector } from 'reselect'; +import { List as ImmutableList } from 'immutable'; +import { debounce } from 'lodash'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import LoadGap from 'flavours/glitch/components/load_gap'; +import Icon from 'flavours/glitch/components/icon'; + +import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, +}); + +const getNotifications = createSelector([ + state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), + state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), + state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + state => state.getIn(['notifications', 'items']), +], (showFilterBar, allowedType, excludedTypes, notifications) => { + if (!showFilterBar || allowedType === 'all') { + // used if user changed the notification settings after loading the notifications from the server + // otherwise a list of notifications will come pre-filtered from the backend + // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category + return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); + } + return notifications.filter(item => item !== null && allowedType === item.get('type')); +}); + +const mapStateToProps = state => ({ + showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']), + notifications: getNotifications(state), + localSettings: state.get('local_settings'), + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, + hasMore: state.getIn(['notifications', 'hasMore']), + numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), +}); + +/* glitch */ +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + onMount() { + dispatch(mountNotifications()); + }, + onUnmount() { + dispatch(unmountNotifications()); + }, + dispatch, +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class Notifications extends React.PureComponent { + + static propTypes = { + columnId: PropTypes.string, + notifications: ImmutablePropTypes.list.isRequired, + showFilterBar: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + intl: PropTypes.object.isRequired, + isLoading: PropTypes.bool, + isUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, + numPending: PropTypes.number, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, + onEnterCleaningMode: PropTypes.func, + onMount: PropTypes.func, + onUnmount: PropTypes.func, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + animatingNCD: false, + }; + + handleLoadGap = (maxId) => { + this.props.dispatch(expandNotifications({ maxId })); + }; + + handleLoadOlder = debounce(() => { + const last = this.props.notifications.last(); + this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); + }, 300, { leading: true }); + + handleLoadPending = () => { + this.props.dispatch(loadPending()); + }; + + handleScrollToTop = debounce(() => { + this.props.dispatch(scrollTopNotifications(true)); + }, 100); + + handleScroll = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + }, 100); + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('NOTIFICATIONS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setColumnRef = c => { + this.column = c; + } + + handleMoveUp = id => { + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; + this._selectChild(elementIndex, true); + } + + handleMoveDown = id => { + const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; + this._selectChild(elementIndex, false); + } + + _selectChild (index, align_top) { + const container = this.column.node; + const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + if (align_top && container.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); + } + element.focus(); + } + } + + componentDidMount () { + const { onMount } = this.props; + if (onMount) { + onMount(); + } + } + + componentWillUnmount () { + const { onUnmount } = this.props; + if (onUnmount) { + onUnmount(); + } + } + + handleTransitionEndNCD = () => { + this.setState({ animatingNCD: false }); + } + + onEnterCleaningMode = () => { + this.setState({ animatingNCD: true }); + this.props.onEnterCleaningMode(!this.props.notifCleaningActive); + } + + render () { + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; + const { notifCleaning, notifCleaningActive } = this.props; + const { animatingNCD } = this.state; + const pinned = !!columnId; + const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; + + let scrollableContent = null; + + const filterBarContainer = showFilterBar + ? (<FilterBarContainer />) + : null; + + if (isLoading && this.scrollableContent) { + scrollableContent = this.scrollableContent; + } else if (notifications.size > 0 || hasMore) { + scrollableContent = notifications.map((item, index) => item === null ? ( + <LoadGap + key={'gap:' + notifications.getIn([index + 1, 'id'])} + disabled={isLoading} + maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null} + onClick={this.handleLoadGap} + /> + ) : ( + <NotificationContainer + key={item.get('id')} + notification={item} + accountId={item.get('account')} + onMoveUp={this.handleMoveUp} + onMoveDown={this.handleMoveDown} + /> + )); + } else { + scrollableContent = null; + } + + this.scrollableContent = scrollableContent; + + const scrollContainer = ( + <ScrollableList + scrollKey={`notifications-${columnId}`} + trackScroll={!pinned} + isLoading={isLoading} + showLoading={isLoading && notifications.size === 0} + hasMore={hasMore} + numPending={numPending} + emptyMessage={emptyMessage} + onLoadMore={this.handleLoadOlder} + onLoadPending={this.handleLoadPending} + onScrollToTop={this.handleScrollToTop} + onScroll={this.handleScroll} + shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} + > + {scrollableContent} + </ScrollableList> + ); + + const notifCleaningButtonClassName = classNames('column-header__button', { + 'active': notifCleaningActive, + }); + + const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', { + 'collapsed': !notifCleaningActive, + 'animating': animatingNCD, + }); + + const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); + + const notifCleaningButton = ( + <button + aria-label={msgEnterNotifCleaning} + title={msgEnterNotifCleaning} + onClick={this.onEnterCleaningMode} + className={notifCleaningButtonClassName} + > + <Icon id='eraser' /> + </button> + ); + + const notifCleaningDrawer = ( + <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}> + <div className='column-header__collapsible-inner nopad-drawer'> + {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null } + </div> + </div> + ); + + return ( + <Column + bindToDocument={!multiColumn} + ref={this.setColumnRef} + name='notifications' + extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null} + label={intl.formatMessage(messages.title)} + > + <ColumnHeader + icon='bell' + active={isUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + localSettings={this.props.localSettings} + extraButton={notifCleaningButton} + appendContent={notifCleaningDrawer} + > + <ColumnSettingsContainer /> + </ColumnHeader> + {filterBarContainer} + {scrollContainer} + </Column> + ); + } + +} |