diff --git a/app/javascript/mastodon/actions/local_settings.js b/app/javascript/mastodon/actions/local_settings.js new file mode 100644 index 000000000..742a1eec2 --- /dev/null +++ b/app/javascript/mastodon/actions/local_settings.js @@ -0,0 +1,20 @@ +export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE'; + +export function changeLocalSetting(key, value) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_CHANGE, + key, + value, + }); + + dispatch(saveLocalSettings()); + }; +}; + +export function saveLocalSettings() { + return (_, getState) => { + const localSettings = getState().get('localSettings').toJS(); + localStorage.setItem('mastodon-settings', JSON.stringify(localSettings)); + }; +}; diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 3bd89902f..3468a7944 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -24,6 +24,11 @@ addLocaleData(localeData); const store = configureStore(); const initialState = JSON.parse(document.getElementById('initial-state').textContent); +try { + initialState.localSettings = JSON.parse(localStorage.getItem('mastodon-settings')); +} catch (e) { + initialState.localSettings = {}; +} store.dispatch(hydrateStore(initialState)); export default class Mastodon extends React.PureComponent { diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 747fe4216..512167193 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -4,8 +4,9 @@ import NavigationContainer from './containers/navigation_container'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { changeLocalSetting } from '../../actions/local_settings'; import Link from 'react-router-dom/Link'; -import { injectIntl, defineMessages } from 'react-intl'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import SearchContainer from './containers/search_container'; import Motion from 'react-motion/lib/Motion'; import spring from 'react-motion/lib/spring'; @@ -21,6 +22,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + layout: state.getIn(['localSettings', 'layout']), }); @connect(mapStateToProps) @@ -32,6 +34,7 @@ export default class Compose extends React.PureComponent { multiColumn: PropTypes.bool, showSearch: PropTypes.bool, intl: PropTypes.object.isRequired, + layout: PropTypes.string, }; componentDidMount () { @@ -42,8 +45,14 @@ export default class Compose extends React.PureComponent { this.props.dispatch(unmountCompose()); } + onLayoutClick = (e) => { + const layout = e.currentTarget.getAttribute('data-mastodon-layout'); + this.props.dispatch(changeLocalSetting(['layout'], layout)); + e.preventDefault(); + } + render () { - const { multiColumn, showSearch, intl } = this.props; + const { multiColumn, showSearch, intl, layout } = this.props; let header = ''; @@ -59,6 +68,47 @@ export default class Compose extends React.PureComponent { ); } + let layoutContent = ''; + + switch (layout) { + case 'single': + layoutContent = ( +
+

+ +

+

+ +

+
+ ); + break; + case 'multiple': + layoutContent = ( +
+

+ +

+

+ +

+
+ ); + break; + default: + layoutContent = ( +
+

+ +

+

+ +

+
+ ); + break; + } + return (
{header} @@ -79,6 +129,9 @@ export default class Compose extends React.PureComponent { }
+ + {layoutContent} + ); } diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 8453679b0..e5915ffe0 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -74,12 +74,17 @@ class WrappedRoute extends React.Component { } -@connect() +const mapStateToProps = state => ({ + layout: state.getIn(['localSettings', 'layout']), +}); + +@connect(mapStateToProps) export default class UI extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, children: PropTypes.node, + layout: PropTypes.string, }; state = { @@ -176,12 +181,23 @@ export default class UI extends React.PureComponent { render () { const { width, draggingOver } = this.state; - const { children } = this.props; + const { children, layout } = this.props; + + const columnsClass = layout => { + switch (layout) { + case 'single': + return 'single-column'; + case 'multiple': + return 'multi-columns'; + default: + return 'auto-columns'; + } + }; return ( -
+
- + diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js index 992e63727..014a9a8d5 100644 --- a/app/javascript/mastodon/is_mobile.js +++ b/app/javascript/mastodon/is_mobile.js @@ -1,7 +1,14 @@ const LAYOUT_BREAKPOINT = 1024; -export function isMobile(width) { - return width <= LAYOUT_BREAKPOINT; +export function isMobile(width, columns) { + switch (columns) { + case 'multiple': + return false; + case 'single': + return true; + default: + return width <= LAYOUT_BREAKPOINT; + } }; const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index dd790f659..803d9b292 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -658,6 +658,22 @@ { "defaultMessage": "Logout", "id": "navigation_bar.logout" + }, + { + "defaultMessage": "Your current layout is:", + "id": "layout.current_is" + }, + { + "defaultMessage": "Mobile", + "id": "layout.mobile" + }, + { + "defaultMessage": "Desktop", + "id": "layout.desktop" + }, + { + "defaultMessage": "Auto", + "id": "layout.auto" } ], "path": "app/javascript/mastodon/features/compose/index.json" diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 8fb409618..c19d4aa02 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -79,6 +79,10 @@ "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", "home.settings": "Column settings", + "layout.auto": "Auto", + "layout.current_is": "Your current layout is:", + "layout.desktop": "Desktop", + "layout.mobile": "Mobile", "lightbox.close": "Close", "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index be402a16b..24f7f94a6 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -14,6 +14,7 @@ import relationships from './relationships'; import search from './search'; import notifications from './notifications'; import settings from './settings'; +import localSettings from './local_settings'; import status_lists from './status_lists'; import cards from './cards'; import reports from './reports'; @@ -36,6 +37,7 @@ export default combineReducers({ search, notifications, settings, + localSettings, cards, reports, contexts, diff --git a/app/javascript/mastodon/reducers/local_settings.js b/app/javascript/mastodon/reducers/local_settings.js new file mode 100644 index 000000000..529d31ebb --- /dev/null +++ b/app/javascript/mastodon/reducers/local_settings.js @@ -0,0 +1,20 @@ +import { LOCAL_SETTING_CHANGE } from '../actions/local_settings'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + layout: 'auto', +}); + +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')); + case LOCAL_SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index ddad7a4fc..9a15a1fe3 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -6,6 +6,7 @@ import uuid from '../uuid'; const initialState = Immutable.Map({ onboarded: false, + layout: 'auto', home: Immutable.Map({ shows: Immutable.Map({ diff --git a/app/javascript/styles/_mixins.scss b/app/javascript/styles/_mixins.scss index 455062135..7412991b8 100644 --- a/app/javascript/styles/_mixins.scss +++ b/app/javascript/styles/_mixins.scss @@ -10,3 +10,33 @@ height: $size; background-size: $size $size; } + +@mixin single-column($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .single-column #{$parent} { + @content; + } +} + +@mixin limited-single-column($media, $parent: '&') { + .auto-columns #{$parent}, .single-column #{$parent} { + @media #{$media} { + @content; + } + } +} + +@mixin multi-columns($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .multi-columns #{$parent} { + @content; + } +} diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 025ef2f64..af9da6c37 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1328,11 +1328,12 @@ justify-content: flex-start; overflow-x: auto; position: relative; + padding: 10px; } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .columns-area { - padding: 10px; + padding: 0; } } @@ -1386,18 +1387,17 @@ } } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .tabs-bar { - margin: 10px; - margin-bottom: 0; + margin: 0; } .search { - margin-bottom: 10px; + margin-bottom: 0; } } -@media screen and (max-width: 1024px) { +@include single-column('screen and (max-width: 1024px)', $parent: null) { .column, .drawer { width: 100%; @@ -1414,7 +1414,7 @@ } } -@media screen and (min-width: 1025px) { +@include multi-columns('screen and (min-width: 1025px)', $parent: null) { .columns-area { padding: 0; } @@ -1447,28 +1447,26 @@ .drawer__pager { box-sizing: border-box; padding: 0; - flex-grow: 1; + flex: 0 0 auto; position: relative; overflow: hidden; - display: flex; } .drawer__inner { - position: absolute; - top: 0; - left: 0; background: lighten($ui-base-color, 13%); box-sizing: border-box; padding: 0; - display: flex; - flex-direction: column; overflow: hidden; overflow-y: auto; width: 100%; - height: 100%; &.darker { + position: absolute; + top: 0; + left: 0; background: $ui-base-color; + width: 100%; + height: 100%; } } @@ -1496,11 +1494,32 @@ } } +.layout__selector { + margin-top: 20px; + + a { + text-decoration: underline; + cursor: pointer; + color: lighten($ui-base-color, 26%); + } + + b { + font-weight: bold; + } + + p { + font-size: 13px; + color: $ui-secondary-color; + } +} + .tabs-bar { display: flex; background: lighten($ui-base-color, 8%); flex: 0 0 auto; overflow-y: auto; + margin: 10px; + margin-bottom: 0; } .tabs-bar__link { @@ -1528,7 +1547,7 @@ &:hover, &:focus, &:active { - @media screen and (min-width: 1025px) { + @include multi-columns('screen and (min-width: 1025px)') { background: lighten($ui-base-color, 14%); transition: all 100ms linear; } @@ -1540,7 +1559,7 @@ } } -@media screen and (min-width: 600px) { +@include limited-single-column('screen and (max-width: 600px)', $parent: null) { .tabs-bar__link { span { display: inline; @@ -1548,7 +1567,7 @@ } } -@media screen and (min-width: 1025px) { +@include multi-columns('screen and (min-width: 1025px)', $parent: null) { .tabs-bar { display: none; } @@ -1737,7 +1756,7 @@ } &.hidden-on-mobile { - @media screen and (max-width: 1024px) { + @include single-column('screen and (max-width: 1024px)') { display: none; } } @@ -1781,7 +1800,7 @@ outline: 0; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } @@ -1798,7 +1817,7 @@ padding-right: 10px + 22px; resize: none; - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { height: 100px !important; // prevent auto-resize textarea resize: vertical; } @@ -1911,7 +1930,7 @@ border-bottom-color: $ui-highlight-color; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } @@ -2114,7 +2133,7 @@ button.icon-button.active i.fa-retweet { } &.hidden-on-mobile { - @media screen and (max-width: 1024px) { + @include single-column('screen and (max-width: 1024px)') { display: none; } } @@ -2872,6 +2891,7 @@ button.icon-button.active i.fa-retweet { .search { position: relative; + margin-bottom: 10px; } .search__input { @@ -2904,7 +2924,7 @@ button.icon-button.active i.fa-retweet { background: lighten($ui-base-color, 4%); } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } diff --git a/app/javascript/styles/custom.scss b/app/javascript/styles/custom.scss index b03231102..7a0509842 100644 --- a/app/javascript/styles/custom.scss +++ b/app/javascript/styles/custom.scss @@ -1,6 +1,6 @@ @import 'application'; -@media screen and (min-width: 1300px) { +@include multi-columns('screen and (min-width: 1300px)', $parent: null) { .column { flex-grow: 1 !important; max-width: 400px;