diff --git a/app/javascript/mastodon/actions/local_settings.js b/app/javascript/mastodon/actions/local_settings.js
new file mode 100644
index 0000000000..742a1eec2c
--- /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 3bd89902f6..3468a79448 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 747fe42164..5121671935 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 = (
+
{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 8453679b05..e5915ffe05 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 992e63727a..014a9a8d5f 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 dd790f659b..803d9b292e 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 8fb409618b..c19d4aa024 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 be402a16b1..24f7f94a6d 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 0000000000..529d31ebba
--- /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 ddad7a4fc2..9a15a1fe30 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 455062135a..7412991b8b 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 025ef2f64b..af9da6c37d 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 b032311020..7a0509842c 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;