diff options
19 files changed, 561 insertions, 94 deletions
diff --git a/app/javascript/mastodon/actions/local_settings.js b/app/javascript/mastodon/actions/local_settings.js index 742a1eec2..18e0c245c 100644 --- a/app/javascript/mastodon/actions/local_settings.js +++ b/app/javascript/mastodon/actions/local_settings.js @@ -14,7 +14,7 @@ export function changeLocalSetting(key, value) { export function saveLocalSettings() { return (_, getState) => { - const localSettings = getState().get('localSettings').toJS(); + const localSettings = getState().get('local_settings').toJS(); localStorage.setItem('mastodon-settings', JSON.stringify(localSettings)); }; }; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 75e8c9640..33e4a25e4 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -25,6 +25,7 @@ export default class StatusOrReblog extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, + settings: ImmutablePropTypes.map, wrapped: PropTypes.bool, onReply: PropTypes.func, onFavourite: PropTypes.func, @@ -47,6 +48,7 @@ export default class StatusOrReblog extends ImmutablePureComponent { updateOnProps = [ 'status', 'account', + 'settings', 'wrapped', 'me', 'boostModal', @@ -97,6 +99,7 @@ class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, + settings: ImmutablePropTypes.map, wrapped: PropTypes.bool, onReply: PropTypes.func, onFavourite: PropTypes.func, @@ -126,6 +129,7 @@ class Status extends ImmutablePureComponent { updateOnProps = [ 'status', 'account', + 'settings', 'wrapped', 'me', 'boostModal', @@ -140,7 +144,8 @@ class Status extends ImmutablePureComponent { ] componentWillReceiveProps (nextProps) { - if (nextProps.collapse !== this.props.collapse && nextProps.collapse !== undefined) this.setState({ isCollapsed: !!nextProps.collapse }); + if (!nextProps.settings.getIn(['collapsed', 'enabled'])) this.collapse(false); + else if (nextProps.collapse !== this.props.collapse && nextProps.collapse !== undefined) this.collapse(this.props.collapse); } shouldComponentUpdate (nextProps, nextState) { @@ -165,8 +170,13 @@ class Status extends ImmutablePureComponent { componentDidMount () { const node = this.node; - if (this.props.collapse !== undefined) this.setState({ isCollapsed: !!this.props.collapse }); - else if (node.clientHeight > 400) this.setState({ isCollapsed: true }); + const { collapse, settings, status } = this.props; + + if (collapse !== undefined) this.collapse(collapse); + else if (settings.getIn(['collapsed', 'auto', 'all'])) this.collapse(); + else if (settings.getIn(['collapsed', 'auto', 'lengthy']) && node.clientHeight > 400) this.collapse(); + else if (settings.getIn(['collapsed', 'auto', 'replies']) && status.get('in_reply_to_id', null) !== null) this.collapse(); + else if (settings.getIn(['collapsed', 'auto', 'media']) && status.get('media_attachments').size > 0) this.collapse(); if (!this.props.intersectionObserverWrapper) { // TODO: enable IntersectionObserver optimization for notification statuses. @@ -186,6 +196,11 @@ class Status extends ImmutablePureComponent { this.componentMounted = false; } + collapse = (collapsedOrNot) => { + if (collapsedOrNot === undefined) collapsedOrNot = true; + if (this.props.settings.getIn(['collapsed', 'enabled'])) this.setState({ isCollapsed: !!collapsedOrNot }); + } + handleIntersection = (entry) => { // Edge 15 doesn't support isIntersecting, but we can infer it // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/ @@ -247,20 +262,23 @@ class Status extends ImmutablePureComponent { }; handleCollapsedClick = () => { - this.setState({ isCollapsed: !this.state.isCollapsed, isExpanded: false }); + this.collapse(!this.state.isCollapsed); + this.setState({ isExpanded: false }); } render () { let media = null; let mediaType = null; - let thumb = null; let statusAvatar; // Exclude intersectionObserverWrapper from `other` variable // because intersection is managed in here. - const { status, account, intersectionObserverWrapper, intl, ...other } = this.props; + const { status, account, settings, intersectionObserverWrapper, intl, ...other } = this.props; const { isExpanded, isIntersecting, isHidden, isCollapsed } = this.state; + + let background = settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds']) ? status.getIn(['account', 'header']) : null; + if (status === null) { return null; } @@ -280,12 +298,12 @@ class Status extends ImmutablePureComponent { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />; mediaType = <i className='fa fa-fw fa-video-camera' aria-hidden='true' />; - if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0)) thumb = status.getIn(['media_attachments', 0]).get('preview_url'); } else { media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; mediaType = status.get('media_attachments').size > 1 ? <i className='fa fa-fw fa-th-large' aria-hidden='true' /> : <i className='fa fa-fw fa-picture-o' aria-hidden='true' />; - if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0)) thumb = status.getIn(['media_attachments', 0]).get('preview_url'); } + + if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) background = status.getIn(['media_attachments', 0]).get('preview_url'); } if (account === undefined || account === null) { @@ -295,19 +313,19 @@ class Status extends ImmutablePureComponent { } return ( - <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')} ${isCollapsed ? 'status-collapsed' : ''}`} data-id={status.get('id')} ref={this.handleRef} style={{ backgroundImage: thumb && isCollapsed ? 'url(' + thumb + ')' : 'none' }}> + <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')} ${isCollapsed ? 'status-collapsed' : ''}`} data-id={status.get('id')} ref={this.handleRef} style={{ backgroundImage: background && isCollapsed ? 'url(' + background + ')' : 'none' }}> <div className='status__info'> <div className='status__info__icons'> {mediaType} - <IconButton + {settings.getIn(['collapsed', 'enabled']) ? <IconButton className='status__collapse-button' animate flip active={isCollapsed} title={isCollapsed ? intl.formatMessage(messages.uncollapse) : intl.formatMessage(messages.collapse)} icon='angle-double-up' onClick={this.handleCollapsedClick} - /> + /> : null} </div> <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 3468a7944..398f7d243 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -25,9 +25,9 @@ addLocaleData(localeData); const store = configureStore(); const initialState = JSON.parse(document.getElementById('initial-state').textContent); try { - initialState.localSettings = JSON.parse(localStorage.getItem('mastodon-settings')); + initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings')); } catch (e) { - initialState.localSettings = {}; + initialState.local_settings = {}; } store.dispatch(hydrateStore(initialState)); diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 438ecfe43..bf4ef5532 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -34,6 +34,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props.id), me: state.getIn(['meta', 'me']), + settings: state.get('local_settings'), boostModal: state.getIn(['meta', 'boost_modal']), deleteModal: state.getIn(['meta', 'delete_modal']), autoPlayGif: state.getIn(['meta', 'auto_play_gif']), diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 512167193..843dcc96c 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -4,6 +4,7 @@ import NavigationContainer from './containers/navigation_container'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { openModal } from '../../actions/modal'; import { changeLocalSetting } from '../../actions/local_settings'; import Link from 'react-router-dom/Link'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; @@ -16,13 +17,13 @@ const messages = defineMessages({ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, - preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, }); const mapStateToProps = state => ({ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), - layout: state.getIn(['localSettings', 'layout']), + layout: state.getIn(['local_settings', 'layout']), }); @connect(mapStateToProps) @@ -51,6 +52,10 @@ export default class Compose extends React.PureComponent { e.preventDefault(); } + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + render () { const { multiColumn, showSearch, intl, layout } = this.props; @@ -62,7 +67,7 @@ export default class Compose extends React.PureComponent { <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role='img' aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link> <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)}><i role='img' aria-label={intl.formatMessage(messages.community)} className='fa fa-fw fa-users' /></Link> <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role='img' aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link> - <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role='img' aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a> + <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)}><i role='img' aria-label={intl.formatMessage(messages.settings)} className='fa fa-fw fa-cogs' /></a> <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role='img' aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a> </div> ); diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index ac93b3d47..684612b1c 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -4,6 +4,7 @@ import ColumnLink from '../ui/components/column_link'; import ColumnSubheading from '../ui/components/column_subheading'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; +import { openModal } from '../../actions/modal'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -17,6 +18,7 @@ const messages = defineMessages({ settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, @@ -39,8 +41,13 @@ export default class GettingStarted extends ImmutablePureComponent { me: ImmutablePropTypes.map.isRequired, columns: ImmutablePropTypes.list, multiColumn: PropTypes.bool, + dispatch: PropTypes.func.isRequired, }; + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + render () { const { intl, me, columns, multiColumn } = this.props; @@ -79,27 +86,30 @@ export default class GettingStarted extends ImmutablePureComponent { return ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> - <div className='getting-started__wrapper'> - <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> - {navItems} - <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> - <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> - <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> - <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> - </div> + <div className='scrollable optionally-scrollable'> + <div className='getting-started__wrapper'> + <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> + {navItems} + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> + <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} /> + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> + </div> - <div className='getting-started__footer scrollable optionally-scrollable'> - <div className='static-content getting-started'> - <p> - <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a> - </p> - <p> - <FormattedMessage - id='getting_started.open_source_notice' - defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' - values={{ github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} - /> - </p> + <div className='getting-started__footer'> + <div className='static-content getting-started'> + <p> + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a> + </p> + <p> + <FormattedMessage + id='getting_started.open_source_notice' + defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' + values={{ github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} + /> + </p> + </div> </div> </div> </Column> diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index bf580794d..6c1985174 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -12,6 +12,7 @@ export default class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, + settings: ImmutablePropTypes.map.isRequired, }; renderFollow (account, link) { @@ -34,7 +35,7 @@ export default class Notification extends ImmutablePureComponent { return <StatusContainer id={notification.get('status')} withDismiss />; } - renderFavourite (notification, link) { + renderFavourite (notification, settings, link) { return ( <div className='notification notification-favourite'> <div className='notification__message'> @@ -44,12 +45,12 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse={settings.getIn(['collapsed', 'auto', 'notifications'])} withDismiss /> </div> ); } - renderReblog (notification, link) { + renderReblog (notification, settings, link) { return ( <div className='notification notification-reblog'> <div className='notification__message'> @@ -59,13 +60,13 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse={settings.getIn(['collapsed', 'auto', 'notifications'])} withDismiss /> </div> ); } render () { - const { notification } = this.props; + const { notification, settings } = this.props; const account = notification.get('account'); const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; @@ -77,9 +78,9 @@ export default class Notification extends ImmutablePureComponent { case 'mention': return this.renderMention(notification); case 'favourite': - return this.renderFavourite(notification, link); + return this.renderFavourite(notification, settings, link); case 'reblog': - return this.renderReblog(notification, link); + return this.renderReblog(notification, settings, link); } return null; diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 786222967..66baf98a1 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -7,6 +7,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ notification: getNotification(state, props.notification, props.accountId), + settings: state.get('local_settings'), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js index cbdb6534f..cbc926581 100644 --- a/app/javascript/mastodon/features/ui/components/column_link.js +++ b/app/javascript/mastodon/features/ui/components/column_link.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Link from 'react-router-dom/Link'; -const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { +const ColumnLink = ({ icon, text, to, onClick, href, method, hideOnMobile }) => { if (href) { return ( <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}> @@ -10,13 +10,20 @@ const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { {text} </a> ); - } else { + } else if (to) { return ( <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}> <i className={`fa fa-fw fa-${icon} column-link__icon`} /> {text} </Link> ); + } else { + return ( + <a onClick={onClick} role='button' tabIndex='0' className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </a> + ); } }; @@ -24,6 +31,7 @@ ColumnLink.propTypes = { icon: PropTypes.string.isRequired, text: PropTypes.string.isRequired, to: PropTypes.string, + onClick: PropTypes.func, href: PropTypes.string, method: PropTypes.string, hideOnMobile: PropTypes.bool, diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 48b048eb7..3e3894441 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -6,6 +6,7 @@ import VideoModal from './video_modal'; import BoostModal from './boost_modal'; import ConfirmationModal from './confirmation_modal'; import ReportModal from './report_modal'; +import SettingsModal from '../containers/settings_modal_container'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; @@ -16,6 +17,7 @@ const MODAL_COMPONENTS = { 'BOOST': BoostModal, 'CONFIRM': ConfirmationModal, 'REPORT': ReportModal, + 'SETTINGS': SettingsModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/settings_modal.js b/app/javascript/mastodon/features/ui/components/settings_modal.js new file mode 100644 index 000000000..4fc059ac2 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/settings_modal.js @@ -0,0 +1,212 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; + +class SettingsItem extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + item: PropTypes.array.isRequired, + id: PropTypes.string.isRequired, + dependsOn: PropTypes.array, + dependsOnNot: PropTypes.array, + children: PropTypes.element.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = (e) => { + const { item, onChange } = this.props; + onChange(item, e); + } + + render () { + const { settings, item, id, children, dependsOn, dependsOnNot } = this.props; + let enabled = true; + + if (dependsOn) { + for (let i = 0; i < dependsOn.length; i++) { + enabled = enabled && settings.getIn(dependsOn[i]); + } + } + if (dependsOnNot) { + for (let i = 0; i < dependsOnNot.length; i++) { + enabled = enabled && !settings.getIn(dependsOnNot[i]); + } + } + + return ( + <label htmlFor={id}> + <input + id={id} + type='checkbox' + checked={settings.getIn(item)} + onChange={this.handleChange} + disabled={!enabled} + /> + {children} + </label> + ); + } + +} + +export default class SettingsModal extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + toggleSetting: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + }; + + state = { + currentIndex: 0, + }; + + General = () => { + return ( + <div> + <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1> + <SettingsItem + settings={this.props.settings} + item={['stretch']} + id='mastodon-settings--stretch' + onChange={this.props.toggleSetting} + > + <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' /> + </SettingsItem> + </div> + ); + } + + CollapsedStatuses = () => { + return ( + <div> + <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'enabled']} + id='mastodon-settings--collapsed-enabled' + onChange={this.props.toggleSetting} + > + <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' /> + </SettingsItem> + <section> + <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'all']} + id='mastodon-settings--collapsed-auto-all' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'notifications']} + id='mastodon-settings--collapsed-auto-notifications' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'lengthy']} + id='mastodon-settings--collapsed-auto-lengthy' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'replies']} + id='mastodon-settings--collapsed-auto-replies' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'media']} + id='mastodon-settings--collapsed-auto-media' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' /> + </SettingsItem> + </section> + <section> + <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'backgrounds', 'user_backgrounds']} + id='mastodon-settings--collapsed-user-backgrouns' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'backgrounds', 'preview_images']} + id='mastodon-settings--collapsed-preview-images' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' /> + </SettingsItem> + </section> + </div> + ); + } + + navigateTo = (e) => + this.setState({ currentIndex: +e.currentTarget.getAttribute('data-mastodon-navigation_index') }); + + render () { + + const { General, CollapsedStatuses, navigateTo } = this; + const { onClose } = this.props; + const { currentIndex } = this.state; + + return ( + <div className='modal-root__modal settings-modal'> + + <nav className='settings-modal__navigation'> + <a onClick={navigateTo} role='button' data-mastodon-navigation_index='0' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 0 ? ' active' : ''}`}> + <FormattedMessage id='settings.general' defaultMessage='General' /> + </a> + <a onClick={navigateTo} role='button' data-mastodon-navigation_index='1' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 1 ? ' active' : ''}`}> + <FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /> + </a> + <a href='/settings/preferences' className='settings-modal__navigation-item'> + <i className='fa fa-fw fa-cogs' /> <FormattedMessage id='settings.preferences' defaultMessage='User preferences' /> + </a> + <a onClick={onClose} role='button' tabIndex='0' className='settings-modal__navigation-close'> + <FormattedMessage id='settings.close' defaultMessage='Close' /> + </a> + + </nav> + + <div className='settings-modal__content'> + { + [ + <General />, + <CollapsedStatuses />, + ][currentIndex] || <General /> + } + </div> + + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/containers/settings_modal_container.js b/app/javascript/mastodon/features/ui/containers/settings_modal_container.js new file mode 100644 index 000000000..83565c4b9 --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/settings_modal_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { changeLocalSetting } from '../../../actions/local_settings'; +import { closeModal } from '../../../actions/modal'; +import SettingsModal from '../components/settings_modal'; + +const mapStateToProps = state => ({ + settings: state.get('local_settings'), +}); + +const mapDispatchToProps = dispatch => ({ + toggleSetting (setting, e) { + dispatch(changeLocalSetting(setting, e.target.checked)); + }, + onClose () { + dispatch(closeModal()); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(SettingsModal); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 4d38c2677..824963a53 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -73,7 +73,8 @@ class WrappedRoute extends React.Component { } const mapStateToProps = state => ({ - layout: state.getIn(['localSettings', 'layout']), + layout: state.getIn(['local_settings', 'layout']), + isWide: state.getIn(['local_settings', 'stretch']), }); @connect(mapStateToProps) @@ -83,6 +84,7 @@ export default class UI extends React.PureComponent { dispatch: PropTypes.func.isRequired, children: PropTypes.node, layout: PropTypes.string, + isWide: PropTypes.bool, }; state = { @@ -179,7 +181,7 @@ export default class UI extends React.PureComponent { render () { const { width, draggingOver } = this.state; - const { children, layout } = this.props; + const { children, layout, isWide } = this.props; const columnsClass = layout => { switch (layout) { @@ -193,7 +195,7 @@ export default class UI extends React.PureComponent { }; return ( - <div className={'ui ' + columnsClass(layout)} ref={this.setRef}> + <div className={'ui ' + columnsClass(layout) + (isWide ? ' wide' : '')} ref={this.setRef}> <TabsBar /> <ColumnsAreaContainer singleColumn={isMobile(width, layout)}> <WrappedSwitch> diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 5ab914477..147f6a971 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -189,16 +189,16 @@ { "descriptors": [ { - "defaultMessage": "{name} boosted", - "id": "status.reblogged_by" - }, - { "defaultMessage": "Collapse", "id": "status.collapse" }, { "defaultMessage": "Uncollapse", "id": "status.uncollapse" + }, + { + "defaultMessage": "{name} boosted", + "id": "status.reblogged_by" } ], "path": "app/javascript/mastodon/components/status.json" @@ -652,8 +652,8 @@ "id": "navigation_bar.community_timeline" }, { - "defaultMessage": "Preferences", - "id": "navigation_bar.preferences" + "defaultMessage": "App settings", + "id": "navigation_bar.app_settings" }, { "defaultMessage": "Logout", @@ -668,12 +668,12 @@ "id": "layout.mobile" }, { - "defaultMessage": "Desktop", - "id": "layout.desktop" - }, - { "defaultMessage": "Auto", "id": "layout.auto" + }, + { + "defaultMessage": "Desktop", + "id": "layout.desktop" } ], "path": "app/javascript/mastodon/features/compose/index.json" @@ -744,6 +744,10 @@ "id": "navigation_bar.preferences" }, { + "defaultMessage": "App settings", + "id": "navigation_bar.app_settings" + }, + { "defaultMessage": "Follow requests", "id": "navigation_bar.follow_requests" }, @@ -1073,7 +1077,7 @@ "id": "onboarding.page_one.welcome" }, { - "defaultMessage": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "defaultMessage": "{domain} is an \"instance\" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "id": "onboarding.page_one.federation" }, { @@ -1121,7 +1125,7 @@ "id": "onboarding.page_six.almost_done" }, { - "defaultMessage": "{domain} runs on Glitchsoc, a friendly fork of {Mastodon}. Glitchsoc is fully compatible with any Mastodon instance or app. You can report bugs, request features, or contribute to the code on {github}.", + "defaultMessage": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", "id": "onboarding.page_six.github" }, { @@ -1171,6 +1175,71 @@ { "descriptors": [ { + "defaultMessage": "General", + "id": "settings.general" + }, + { + "defaultMessage": "Wide view (Desktop mode only)", + "id": "settings.wide_view" + }, + { + "defaultMessage": "Collapsed toots", + "id": "settings.collapsed_statuses" + }, + { + "defaultMessage": "Enable collapsed toots", + "id": "settings.enable_collapsed" + }, + { + "defaultMessage": "Automatic collapsing", + "id": "settings.auto_collapse" + }, + { + "defaultMessage": "Everything", + "id": "settings.auto_collapse_all" + }, + { + "defaultMessage": "Notifications", + "id": "settings.auto_collapse_notifications" + }, + { + "defaultMessage": "Lengthy toots", + "id": "settings.auto_collapse_lengthy" + }, + { + "defaultMessage": "Replies", + "id": "settings.auto_collapse_replies" + }, + { + "defaultMessage": "Toots with media", + "id": "settings.auto_collapse_media" + }, + { + "defaultMessage": "Image backgrounds", + "id": "settings.image_backgrounds" + }, + { + "defaultMessage": "Give collapsed toots an image background", + "id": "settings.image_backgrounds_users" + }, + { + "defaultMessage": "Preview collapsed toot media", + "id": "settings.image_backgrounds_media" + }, + { + "defaultMessage": "User preferences", + "id": "settings.global_settings" + }, + { + "defaultMessage": "Close", + "id": "settings.close" + } + ], + "path": "app/javascript/mastodon/features/ui/components/settings_modal.json" + }, + { + "descriptors": [ + { "defaultMessage": "Compose", "id": "tabs_bar.compose" }, @@ -1211,4 +1280,4 @@ ], "path": "app/javascript/mastodon/features/ui/components/video_modal.json" } -] +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index d0c0ca137..fb81aba4a 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -87,6 +87,7 @@ "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", "missing_indicator.label": "Not found", + "navigation_bar.app_settings": "App settings", "navigation_bar.blocks": "Blocked users", "navigation_bar.community_timeline": "Local timeline", "navigation_bar.edit_profile": "Edit profile", @@ -146,6 +147,21 @@ "report.target": "Reporting {target}", "search.placeholder": "Search", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "settings.auto_collapse": "Automatic collapsing", + "settings.auto_collapse_all": "Everything", + "settings.auto_collapse_lengthy": "Lengthy toots", + "settings.auto_collapse_media": "Toots with media", + "settings.auto_collapse_notifications": "Notifications", + "settings.auto_collapse_replies": "Replies", + "settings.close": "Close", + "settings.collapsed_statuses": "Collapsed toots", + "settings.enable_collapsed": "Enable collapsed toots", + "settings.general": "General", + "settings.global_settings": "User preferences", + "settings.image_backgrounds": "Image backgrounds", + "settings.image_backgrounds_media": "Preview collapsed toot media", + "settings.image_backgrounds_users": "Give collapsed toots an image background", + "settings.wide_view": "Wide view (Desktop mode only)", "status.cannot_reblog": "This post cannot be boosted", "status.collapse": "Collapse", "status.delete": "Delete", diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 24f7f94a6..0749a4e4a 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -14,7 +14,7 @@ import relationships from './relationships'; import search from './search'; import notifications from './notifications'; import settings from './settings'; -import localSettings from './local_settings'; +import local_settings from './local_settings'; import status_lists from './status_lists'; import cards from './cards'; import reports from './reports'; @@ -37,7 +37,7 @@ export default combineReducers({ search, notifications, settings, - localSettings, + local_settings, cards, reports, contexts, diff --git a/app/javascript/mastodon/reducers/local_settings.js b/app/javascript/mastodon/reducers/local_settings.js index 529d31ebb..c6581fe2e 100644 --- a/app/javascript/mastodon/reducers/local_settings.js +++ b/app/javascript/mastodon/reducers/local_settings.js @@ -3,7 +3,22 @@ import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; const initialState = Immutable.Map({ - layout: 'auto', + layout : 'auto', + stretch : true, + collapsed : { + enabled : true, + auto : { + all : false, + notifications : true, + lengthy : true, + replies : false, + media : false, + }, + backgrounds : { + user_backgrounds : false, + preview_images : false, + }, + }, }); const hydrate = (state, localSettings) => state.mergeDeep(localSettings); @@ -11,7 +26,7 @@ const hydrate = (state, localSettings) => state.mergeDeep(localSettings); export default function localSettings(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - return hydrate(state, action.state.get('localSettings')); + return hydrate(state, action.state.get('local_settings')); case LOCAL_SETTING_CHANGE: return state.setIn(action.key, action.value); default: diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 9bf386c0c..2ed6d14a3 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1351,6 +1351,10 @@ overflow-x: auto; position: relative; padding: 10px; + + .wide & { + justify-content: center; + } } @include limited-single-column('screen and (max-width: 360px)', $parent: null) { @@ -1367,6 +1371,12 @@ flex-direction: column; overflow: hidden; + .wide & { + flex: auto; + min-width: 330px; + max-width: 400px; + } + > .scrollable { background: $ui-base-color; } @@ -1387,6 +1397,12 @@ display: flex; flex-direction: column; overflow-y: auto; + + .wide & { + flex: 1 1 200px; + min-width: 300px; + max-width: 400px; + } } .drawer__tab { @@ -1399,11 +1415,12 @@ text-align: center; font-size: 16px; border-bottom: 2px solid transparent; + outline: none; + cursor: pointer; } .column, .drawer { - flex: 1 1 100%; @supports(display: grid) { // hack to fix Chrome <57 contain: strict; } @@ -1419,20 +1436,25 @@ } } -@include single-column('screen and (max-width: 1024px)', $parent: null) { - .column, - .drawer { - width: 100%; - padding: 0; - } +:root { // Overrides .wide stylings for mobile view + @include single-column('screen and (max-width: 1024px)', $parent: null) { + .column, + .drawer { + flex: auto; + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + } - .columns-area { - flex-direction: column; - } + .columns-area { + flex-direction: column; + } - .search__input, - .autosuggest-textarea__textarea { - font-size: 16px; + .search__input, + .autosuggest-textarea__textarea { + font-size: 16px; + } } } @@ -1443,7 +1465,6 @@ .column, .drawer { - flex: 0 0 auto; padding: 10px; padding-left: 5px; padding-right: 5px; @@ -1771,6 +1792,8 @@ font-size: 16px; padding: 15px; text-decoration: none; + cursor: pointer; + outline: none; &:hover { background: lighten($ui-base-color, 11%); @@ -3312,6 +3335,85 @@ button.icon-button.active i.fa-retweet { margin-bottom: 20px; } +.settings-modal { + position: relative; + display: flex; + flex-direction: row; + background: $ui-secondary-color; + color: $ui-base-color; + border-radius: 8px; + height: 80vh; + width: 80vw; + max-width: 740px; + max-height: 450px; + overflow: hidden; + + label { + display: block; + } + + h1 { + font-size: 18px; + font-weight: 500; + line-height: 24px; + margin-bottom: 20px; + } + + h2 { + font-size: 15px; + font-weight: 500; + line-height: 20px; + margin-top: 20px; + margin-bottom: 10px; + } +} + +.settings-modal__navigation { + background: $primary-text-color; + color: $ui-base-color; + width: 200px; + font-size: 15px; + line-height: 20px; + overflow-y: auto; + + .settings-modal__navigation-item, .settings-modal__navigation-close { + display: block; + padding: 15px 20px; + cursor: pointer; + outline: none; + text-decoration: none; + } + + .settings-modal__navigation-item { + background: $primary-text-color; + color: inherit; + border-bottom: 1px $ui-primary-color solid; + transition: background .3s; + + &:hover { + background: $ui-secondary-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + } + } + + .settings-modal__navigation-close { + background: $error-value-color; + color: $primary-text-color; + } +} + +.settings-modal__content { + display: block; + flex: auto; + padding: 15px 20px 15px 20px; + width: 360px; + overflow-y: auto; +} + .onboard-sliders { display: inline-block; max-width: 30px; diff --git a/app/javascript/styles/custom.scss b/app/javascript/styles/custom.scss index 5144e4fb6..6a18fd628 100644 --- a/app/javascript/styles/custom.scss +++ b/app/javascript/styles/custom.scss @@ -1,19 +1,5 @@ @import 'application'; -@include multi-columns('screen and (min-width: 1300px)', $parent: null) { - .column { - flex-grow: 1 !important; - max-width: 400px; - } - - .drawer { - flex-grow: 1 !important; - flex-basis: 200px !important; - min-width: 268px; - max-width: 400px; - } -} - .muted { .status__content p, .status__content a { color: lighten($ui-base-color, 35%); |