about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/javascript/images/mastodon-getting-started.pngbin34539 -> 46174 bytes
-rw-r--r--app/javascript/mastodon/actions/local_settings.js20
-rw-r--r--app/javascript/mastodon/components/icon_button.js3
-rw-r--r--app/javascript/mastodon/components/status.js83
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js3
-rw-r--r--app/javascript/mastodon/components/status_content.js41
-rw-r--r--app/javascript/mastodon/containers/mastodon.js5
-rw-r--r--app/javascript/mastodon/features/account/components/header.js105
-rw-r--r--app/javascript/mastodon/features/account/util/bio_metadata.js295
-rw-r--r--app/javascript/mastodon/features/compose/index.js57
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js4
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js8
-rw-r--r--app/javascript/mastodon/features/ui/index.js24
-rw-r--r--app/javascript/mastodon/is_mobile.js11
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json34
-rw-r--r--app/javascript/mastodon/locales/en.json14
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/local_settings.js20
-rw-r--r--app/javascript/mastodon/reducers/settings.js1
-rw-r--r--app/javascript/packs/custom.js1
-rw-r--r--app/javascript/packs/public.js4
-rw-r--r--app/javascript/styles/_mixins.scss32
-rw-r--r--app/javascript/styles/about.scss8
-rw-r--r--app/javascript/styles/accounts.scss20
-rw-r--r--app/javascript/styles/components.scss218
-rw-r--r--app/javascript/styles/custom.scss118
-rw-r--r--app/javascript/styles/stream_entries.scss13
-rw-r--r--app/javascript/styles/variables.scss3
-rw-r--r--app/lib/feed_manager.rb6
-rw-r--r--app/models/account.rb18
-rw-r--r--app/services/post_status_service.rb6
-rw-r--r--app/services/reblog_service.rb5
-rw-r--r--app/validators/status_length_validator.rb2
-rw-r--r--app/views/about/_links.html.haml2
-rw-r--r--app/views/about/show.html.haml6
-rw-r--r--app/views/settings/profiles/show.html.haml2
37 files changed, 995 insertions, 203 deletions
diff --git a/app/javascript/images/mastodon-getting-started.png b/app/javascript/images/mastodon-getting-started.png
index e05dd493f..8fe0df76a 100644
--- a/app/javascript/images/mastodon-getting-started.png
+++ b/app/javascript/images/mastodon-getting-started.png
Binary files differdiff --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/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index ac734f5ad..748283853 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -17,6 +17,7 @@ export default class IconButton extends React.PureComponent {
     disabled: PropTypes.bool,
     inverted: PropTypes.bool,
     animate: PropTypes.bool,
+    flip: PropTypes.bool,
     overlay: PropTypes.bool,
   };
 
@@ -69,7 +70,7 @@ export default class IconButton extends React.PureComponent {
     }
 
     return (
-      <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
+      <Motion defaultStyle={{ rotate: this.props.active ? (this.props.flip ? -180 : -360) : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? (this.props.flip ? -180 : -360) : 0, { stiffness: this.props.flip ? 60 : 120, damping: 7 }) : 0 }}>
         {({ rotate }) =>
           <button
             aria-label={this.props.title}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index a837659c2..9e9e1c3c7 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -3,19 +3,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import Avatar from './avatar';
 import AvatarOverlay from './avatar_overlay';
-import RelativeTimestamp from './relative_timestamp';
 import DisplayName from './display_name';
 import MediaGallery from './media_gallery';
 import VideoPlayer from './video_player';
 import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
-import { FormattedMessage } from 'react-intl';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import emojify from '../emoji';
 import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
 
-export default class Status extends ImmutablePureComponent {
+const messages = defineMessages({
+  collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
+  uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
+});
+
+class StatusUnextended extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -36,13 +41,16 @@ export default class Status extends ImmutablePureComponent {
     boostModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
     muted: PropTypes.bool,
+    collapse: PropTypes.bool,
     intersectionObserverWrapper: PropTypes.object,
+    intl: PropTypes.object.isRequired,
   };
 
   state = {
     isExpanded: false,
     isIntersecting: true, // assume intersecting until told otherwise
     isHidden: false, // set to true in requestIdleCallback to trigger un-render
+    isCollapsed: false,
   }
 
   // Avoid checking props that are functions (and whose equality will always
@@ -55,9 +63,17 @@ export default class Status extends ImmutablePureComponent {
     'boostModal',
     'autoPlayGif',
     'muted',
+    'collapse',
+  ]
+
+  updateOnStates = [
+    'isExpanded',
+    'isCollapsed',
   ]
 
-  updateOnStates = ['isExpanded']
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.collapse !== this.props.collapse && nextProps.collapse !== undefined) this.setState({ isCollapsed: !!nextProps.collapse });
+  }
 
   shouldComponentUpdate (nextProps, nextState) {
     if (!nextState.isIntersecting && nextState.isHidden) {
@@ -74,7 +90,16 @@ export default class Status extends ImmutablePureComponent {
     return super.shouldComponentUpdate(nextProps, nextState);
   }
 
+  componentDidUpdate (prevProps, prevState) {
+    if (prevState.isCollapsed !== this.state.isCollapsed) this.saveHeight();
+  }
+
   componentDidMount () {
+    const node = this.node;
+
+    if (this.props.collapse !== undefined) this.setState({ isCollapsed: !!this.props.collapse });
+    else if (node.clientHeight > 400 && !(this.props.status.get('reblog', null) !== null && typeof this.props.status.get('reblog') === 'object')) this.setState({ isCollapsed: true });
+
     if (!this.props.intersectionObserverWrapper) {
       // TODO: enable IntersectionObserver optimization for notification statuses.
       // These are managed in notifications/index.js rather than status_list.js
@@ -135,29 +160,38 @@ export default class Status extends ImmutablePureComponent {
 
   handleClick = () => {
     const { status } = this.props;
-    this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
+    const { isCollapsed } = this.state;
+    if (isCollapsed) this.handleCollapsedClick();
+    else this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
   }
 
   handleAccountClick = (e) => {
     if (e.button === 0) {
       const id = Number(e.currentTarget.getAttribute('data-id'));
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${id}`);
+      if (this.state.isCollapsed) this.handleCollapsedClick();
+      else this.context.router.history.push(`/accounts/${id}`);
     }
   }
 
   handleExpandedToggle = () => {
-    this.setState({ isExpanded: !this.state.isExpanded });
+    this.setState({ isExpanded: !this.state.isExpanded, isCollapsed: false });
   };
 
+  handleCollapsedClick = () => {
+    this.setState({ isCollapsed: !this.state.isCollapsed, 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, ...other } = this.props;
-    const { isExpanded, isIntersecting, isHidden } = this.state;
+    const { status, account, intersectionObserverWrapper, intl, ...other } = this.props;
+    const { isExpanded, isIntersecting, isHidden, isCollapsed } = this.state;
 
     if (status === null) {
       return null;
@@ -198,8 +232,12 @@ export default 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');
       }
     }
 
@@ -210,9 +248,20 @@ export default class Status extends ImmutablePureComponent {
     }
 
     return (
-      <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}>
+      <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__info'>
-          <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+
+          <div className='status__info__icons'>
+            {mediaType}
+            <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}
+            />
+          </div>
 
           <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
             <div className='status__avatar'>
@@ -221,15 +270,21 @@ export default class Status extends ImmutablePureComponent {
 
             <DisplayName account={status.get('account')} />
           </a>
+
         </div>
 
-        <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight} />
+        <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} collapsed={isCollapsed} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight}>
+
+          {isCollapsed ? null : media}
 
-        {media}
+        </StatusContent>
 
-        <StatusActionBar {...this.props} />
+        {isCollapsed ? null : <StatusActionBar status={status} account={account} {...other} />}
       </div>
     );
   }
 
 }
+
+const Status = injectIntl(StatusUnextended);
+export default Status;
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index fd7c99054..6693548c7 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -5,6 +5,7 @@ import IconButton from './icon_button';
 import DropdownMenu from './dropdown_menu';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -144,6 +145,8 @@ export default class StatusActionBar extends ImmutablePureComponent {
         <div className='status__action-bar-dropdown'>
           <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
         </div>
+
+        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 19bde01bd..bcbff5515 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -16,9 +16,11 @@ export default class StatusContent extends React.PureComponent {
   static propTypes = {
     status: ImmutablePropTypes.map.isRequired,
     expanded: PropTypes.bool,
+    collapsed: PropTypes.bool,
     onExpandedToggle: PropTypes.func,
     onHeightUpdate: PropTypes.func,
     onClick: PropTypes.func,
+    children: PropTypes.element,
   };
 
   state = {
@@ -39,6 +41,7 @@ export default class StatusContent extends React.PureComponent {
       } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
         link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
       } else {
+        link.addEventListener('click', this.onLinkClick.bind(this), false);
         link.setAttribute('target', '_blank');
         link.setAttribute('rel', 'noopener');
         link.setAttribute('title', link.href);
@@ -52,10 +55,18 @@ export default class StatusContent extends React.PureComponent {
     }
   }
 
+  onLinkClick = (e) => {
+    if (e.button === 0 && this.props.collapsed) {
+      e.preventDefault();
+      if (this.props.onClick) this.props.onClick();
+    }
+  }
+
   onMentionClick = (mention, e) => {
     if (e.button === 0) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+      if (!this.props.collapsed) this.context.router.history.push(`/accounts/${mention.get('id')}`);
+      else if (this.props.onClick) this.props.onClick();
     }
   }
 
@@ -64,7 +75,8 @@ export default class StatusContent extends React.PureComponent {
 
     if (e.button === 0) {
       e.preventDefault();
-      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+      if (!this.props.collapsed) this.context.router.history.push(`/timelines/tag/${hashtag}`);
+      else if (this.props.onClick) this.props.onClick();
     }
   }
 
@@ -107,7 +119,7 @@ export default class StatusContent extends React.PureComponent {
   }
 
   render () {
-    const { status } = this.props;
+    const { status, children } = this.props;
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
 
@@ -144,7 +156,14 @@ export default class StatusContent extends React.PureComponent {
 
           {mentionsPlaceholder}
 
-          <div className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
+          <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
+
+            <div style={directionStyle} dangerouslySetInnerHTML={content} />
+
+            {children}
+
+          </div>
+
         </div>
       );
     } else if (this.props.onClick) {
@@ -155,8 +174,10 @@ export default class StatusContent extends React.PureComponent {
           style={directionStyle}
           onMouseDown={this.handleMouseDown}
           onMouseUp={this.handleMouseUp}
-          dangerouslySetInnerHTML={content}
-        />
+        >
+          <div dangerouslySetInnerHTML={content} />
+          {children}
+        </div>
       );
     } else {
       return (
@@ -164,8 +185,12 @@ export default class StatusContent extends React.PureComponent {
           ref={this.setRef}
           className='status__content'
           style={directionStyle}
-          dangerouslySetInnerHTML={content}
-        />
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
+        >
+          <div dangerouslySetInnerHTML={content} />
+          {children}
+        </div>
       );
     }
   }
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/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 3239b1085..89274d3d4 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -5,10 +5,9 @@ import emojify from '../../../emoji';
 import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
-import Motion from 'react-motion/lib/Motion';
-import spring from 'react-motion/lib/spring';
-import { connect } from 'react-redux';
+import Avatar from '../../../components/avatar';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { processBio } from '../util/bio_metadata';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -16,61 +15,6 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
 });
 
-const makeMapStateToProps = () => {
-  const mapStateToProps = state => ({
-    autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
-  });
-
-  return mapStateToProps;
-};
-
-class Avatar extends ImmutablePureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
-    autoPlayGif: PropTypes.bool.isRequired,
-  };
-
-  state = {
-    isHovered: false,
-  };
-
-  handleMouseOver = () => {
-    if (this.state.isHovered) return;
-    this.setState({ isHovered: true });
-  }
-
-  handleMouseOut = () => {
-    if (!this.state.isHovered) return;
-    this.setState({ isHovered: false });
-  }
-
-  render () {
-    const { account, autoPlayGif }   = this.props;
-    const { isHovered } = this.state;
-
-    return (
-      <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
-        {({ radius }) =>
-          <a // eslint-disable-line jsx-a11y/anchor-has-content
-            href={account.get('url')}
-            className='account__header__avatar'
-            target='_blank'
-            rel='noopener'
-            style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
-            onMouseOver={this.handleMouseOver}
-            onMouseOut={this.handleMouseOut}
-            onFocus={this.handleMouseOver}
-            onBlur={this.handleMouseOut}
-          />
-        }
-      </Motion>
-    );
-  }
-
-}
-
-@connect(makeMapStateToProps)
 @injectIntl
 export default class Header extends ImmutablePureComponent {
 
@@ -79,7 +23,6 @@ export default class Header extends ImmutablePureComponent {
     me: PropTypes.number.isRequired,
     onFollow: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
-    autoPlayGif: PropTypes.bool.isRequired,
   };
 
   render () {
@@ -122,21 +65,41 @@ export default class Header extends ImmutablePureComponent {
       lockedIcon = <i className='fa fa-lock' />;
     }
 
-    const content         = { __html: emojify(account.get('note')) };
-    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const displayNameHTML    = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const { text, metadata } = processBio(account.get('note'));
 
     return (
-      <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
-        <div>
-          <Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
-
-          <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
-          <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
-          <div className='account__header__content' dangerouslySetInnerHTML={content} />
-
-          {info}
-          {actionBtn}
+      <div className='account__header__wrapper'>
+        <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
+          <div>
+            <a href={account.get('url')} target='_blank' rel='noopener'>
+              <span className='account__header__avatar'><Avatar src={account.get('avatar')} animate size={90} /></span>
+              <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
+            </a>
+            <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
+            <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
+
+            {info}
+            {actionBtn}
+          </div>
         </div>
+
+        {metadata.length && (
+          <table className='account__metadata'>
+            {(() => {
+              let data = [];
+              for (let i = 0; i < metadata.length; i++) {
+                data.push(
+                  <tr key={i}>
+                    <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
+                    <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
+                  </tr>
+                );
+              }
+              return data;
+            })()}
+          </table>
+        ) || null}
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/account/util/bio_metadata.js b/app/javascript/mastodon/features/account/util/bio_metadata.js
new file mode 100644
index 000000000..fc24549b6
--- /dev/null
+++ b/app/javascript/mastodon/features/account/util/bio_metadata.js
@@ -0,0 +1,295 @@
+/*********************************************************************\
+
+                                       To my lovely code maintainers,
+
+  The syntax recognized by the Mastodon frontend for its bio metadata
+  feature is a subset of that provided by the YAML 1.2 specification.
+  In particular, Mastodon recognizes metadata which is provided as an
+  implicit YAML map, where each key-value pair takes up only a single
+  line (no multi-line values are permitted). To simplify the level of
+  processing required, Mastodon metadata frontmatter has been limited
+  to only allow those characters in the `c-printable` set, as defined
+  by the YAML 1.2 specification, instead of permitting those from the
+  `nb-json` characters inside double-quoted strings like YAML proper.
+    ¶ It is important to note that Mastodon only borrows the *syntax*
+  of YAML, not its semantics. This is to say, Mastodon won't make any
+  attempt to interpret the data it receives. `true` will not become a
+  boolean; `56` will not be interpreted as a number. Rather, each key
+  and every value will be read as a string, and as a string they will
+  remain. The order of the pairs is unchanged, and any duplicate keys
+  are preserved. However, YAML escape sequences will be replaced with
+  the proper interpretations according to the YAML 1.2 specification.
+    ¶ The implementation provided below interprets `<br>` as `\n` and
+  allows for an open <p> tag at the beginning of the bio. It replaces
+  the escaped character entities `&apos;` and `&quot;` with single or
+  double quotes, respectively, prior to processing. However, no other
+  escaped characters are replaced, not even those which might have an
+  impact on the syntax otherwise. These minor allowances are provided
+  because the Mastodon backend will insert these things automatically
+  into a bio before sending it through the API, so it is important we
+  account for them. Aside from this, the YAML frontmatter must be the
+  very first thing in the bio, leading with three consecutive hyphen-
+  minues (`---`), and ending with the same or, alternatively, instead
+  with three periods (`...`). No limits have been set with respect to
+  the number of characters permitted in the frontmatter, although one
+  should note that only limited space is provided for them in the UI.
+    ¶ The regular expression used to check the existence of, and then
+  process, the YAML frontmatter has been split into a number of small
+  components in the code below, in the vain hope that it will be much
+  easier to read and to maintain. I leave it to the future readers of
+  this code to determine the extent of my successes in this endeavor.
+
+                                       Sending love + warmth eternal,
+                                       - kibigo [@kibi@glitch.social]
+
+\*********************************************************************/
+
+/*  CONVENIENCE FUNCTIONS  */
+
+const unirex = str => new RegExp(str, 'u');
+const rexstr = exp => '(?:' + exp.source + ')';
+
+/*  CHARACTER CLASSES  */
+
+const DOCUMENT_START    = /^/;
+const DOCUMENT_END      = /$/;
+const ALLOWED_CHAR      =  //  `c-printable` in the YAML 1.2 spec.
+  /[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]/u;
+const WHITE_SPACE       = /[ \t]/;
+const INDENTATION       = / */;  //  Indentation must be only spaces.
+const LINE_BREAK        = /\r?\n|\r|<br\s*\/?>/;
+const ESCAPE_CHAR       = /[0abt\tnvfre "\/\\N_LP]/;
+const HEXADECIMAL_CHARS = /[0-9a-fA-F]/;
+const INDICATOR         = /[-?:,[\]{}&#*!|>'"%@`]/;
+const FLOW_CHAR         = /[,[\]{}]/;
+
+/*  NEGATED CHARACTER CLASSES  */
+
+const NOT_WHITE_SPACE   = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]');
+const NOT_LINE_BREAK    = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]');
+const NOT_INDICATOR     = unirex('(?!' + rexstr(INDICATOR) + ')[^]');
+const NOT_FLOW_CHAR     = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]');
+
+/*  BASIC CONSTRUCTS  */
+
+const ANY_WHITE_SPACE   = unirex(rexstr(WHITE_SPACE) + '*');
+const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*');
+const NEW_LINE          = unirex(
+  rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK)
+);
+const SOME_NEW_LINES    = unirex(
+  '(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+'
+);
+const POSSIBLE_STARTS   = unirex(
+  rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?'
+);
+const POSSIBLE_ENDS     = unirex(
+  rexstr(SOME_NEW_LINES) + '|' +
+  rexstr(DOCUMENT_END) + '|' +
+  rexstr(/<\/p>/)
+);
+const CHARACTER_ESCAPE  = unirex(
+  rexstr(/\\/) +
+  '(?:' +
+    rexstr(ESCAPE_CHAR) + '|' +
+    rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' +
+    rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' +
+    rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' +
+  ')'
+);
+const ESCAPED_CHAR      = unirex(
+  rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' +
+  rexstr(CHARACTER_ESCAPE)
+);
+const ANY_ESCAPED_CHARS = unirex(
+  rexstr(ESCAPED_CHAR) + '*'
+);
+const ESCAPED_APOS      = unirex(
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/)
+);
+const ANY_ESCAPED_APOS  = unirex(
+  rexstr(ESCAPED_APOS) + '*'
+);
+const FIRST_KEY_CHAR    = unirex(
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  rexstr(NOT_INDICATOR) + '|' +
+  rexstr(/[?:-]/) +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  '(?=' + rexstr(NOT_FLOW_CHAR) + ')'
+);
+const FIRST_VALUE_CHAR  = unirex(
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  rexstr(NOT_INDICATOR) + '|' +
+  rexstr(/[?:-]/) +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+  //  Flow indicators are allowed in values.
+);
+const LATER_KEY_CHAR    = unirex(
+  rexstr(WHITE_SPACE) + '|' +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +
+  rexstr(/[^:#]#?/) + '|' +
+  rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+);
+const LATER_VALUE_CHAR  = unirex(
+  rexstr(WHITE_SPACE) + '|' +
+  '(?=' + rexstr(NOT_LINE_BREAK) + ')' +
+  '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +
+  //  Flow indicators are allowed in values.
+  rexstr(/[^:#]#?/) + '|' +
+  rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')'
+);
+
+/*  YAML CONSTRUCTS  */
+
+const YAML_START        = unirex(
+  rexstr(ANY_WHITE_SPACE) + rexstr(/---/)
+);
+const YAML_END          = unirex(
+  rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/)
+);
+const YAML_LOOKAHEAD    = unirex(
+  '(?=' +
+    rexstr(YAML_START) +
+    rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) +
+    rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +
+  ')'
+);
+const YAML_DOUBLE_QUOTE = unirex(
+  rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/)
+);
+const YAML_SINGLE_QUOTE = unirex(
+  rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/)
+);
+const YAML_SIMPLE_KEY   = unirex(
+  rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*'
+);
+const YAML_SIMPLE_VALUE = unirex(
+  rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*'
+);
+const YAML_KEY          = unirex(
+  rexstr(YAML_DOUBLE_QUOTE) + '|' +
+  rexstr(YAML_SINGLE_QUOTE) + '|' +
+  rexstr(YAML_SIMPLE_KEY)
+);
+const YAML_VALUE        = unirex(
+  rexstr(YAML_DOUBLE_QUOTE) + '|' +
+  rexstr(YAML_SINGLE_QUOTE) + '|' +
+  rexstr(YAML_SIMPLE_VALUE)
+);
+const YAML_SEPARATOR    = unirex(
+  rexstr(ANY_WHITE_SPACE) +
+  ':' + rexstr(WHITE_SPACE) +
+  rexstr(ANY_WHITE_SPACE)
+);
+const YAML_LINE         = unirex(
+  '(' + rexstr(YAML_KEY) + ')' +
+  rexstr(YAML_SEPARATOR) +
+  '(' + rexstr(YAML_VALUE) + ')'
+);
+
+/*  FRONTMATTER REGEX  */
+
+const YAML_FRONTMATTER  = unirex(
+  rexstr(POSSIBLE_STARTS) +
+  rexstr(YAML_LOOKAHEAD) +
+  rexstr(YAML_START) + rexstr(SOME_NEW_LINES) +
+  '(?:' +
+    '(' + rexstr(INDENTATION) + ')' +
+    rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
+    '(?:' +
+      '\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) +
+    '){0,4}' +
+  ')?' +
+  rexstr(YAML_END) + rexstr(POSSIBLE_ENDS)
+);
+
+/*  SEARCHES  */
+
+const FIND_YAML_LINES   = unirex(
+  rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE)
+);
+
+/*  STRING PROCESSING  */
+
+function processString(str) {
+  switch (str.charAt(0)) {
+  case '"':
+    return str
+      .substring(1, str.length - 1)
+      .replace(/\\0/g, '\x00')
+      .replace(/\\a/g, '\x07')
+      .replace(/\\b/g, '\x08')
+      .replace(/\\t/g, '\x09')
+      .replace(/\\n/g, '\x0a')
+      .replace(/\\v/g, '\x0b')
+      .replace(/\\f/g, '\x0c')
+      .replace(/\\r/g, '\x0d')
+      .replace(/\\e/g, '\x1b')
+      .replace(/\\ /g, '\x20')
+      .replace(/\\"/g, '\x22')
+      .replace(/\\\//g, '\x2f')
+      .replace(/\\\\/g, '\x5c')
+      .replace(/\\N/g, '\x85')
+      .replace(/\\_/g, '\xa0')
+      .replace(/\\L/g, '\u2028')
+      .replace(/\\P/g, '\u2029')
+      .replace(
+        new RegExp(
+          unirex(
+            rexstr(/\\x/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{2})'
+          ), 'gu'
+        ), (_, n) => String.fromCodePoint('0x' + n)
+      )
+      .replace(
+        new RegExp(
+          unirex(
+            rexstr(/\\u/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{4})'
+          ), 'gu'
+        ), (_, n) => String.fromCodePoint('0x' + n)
+      )
+      .replace(
+        new RegExp(
+          unirex(
+            rexstr(/\\U/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{8})'
+          ), 'gu'
+        ), (_, n) => String.fromCodePoint('0x' + n)
+      );
+  case '\'':
+    return str
+      .substring(1, str.length - 1)
+      .replace(/''/g, '\'');
+  default:
+    return str;
+  }
+}
+
+/*  BIO PROCESSING  */
+
+export function processBio(content) {
+  content = content.replace(/&quot;/g, '"').replace(/&apos;/g, '\'');
+  let result = {
+    text: content,
+    metadata: [],
+  };
+  let yaml = content.match(YAML_FRONTMATTER);
+  if (!yaml) return result;
+  else yaml = yaml[0];
+  let start = content.search(YAML_START);
+  let end = start + yaml.length - yaml.search(YAML_START);
+  result.text = content.substr(0, start) + content.substr(end);
+  let metadata = null;
+  let query = new RegExp(FIND_YAML_LINES, 'g');
+  while ((metadata = query.exec(yaml))) {
+    result.metadata.push([
+      processString(metadata[1]),
+      processString(metadata[2]),
+    ]);
+  }
+  return result;
+}
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 = (
+        <div className='layout__selector'>
+          <p>
+            <FormattedMessage id='layout.current_is' defaultMessage='Your current layout is:' /> <b><FormattedMessage id='layout.mobile' defaultMessage='Mobile' /></b>
+          </p>
+          <p>
+            <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='auto'><FormattedMessage id='layout.auto' defaultMessage='Auto' /></a> • <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='multiple'><FormattedMessage id='layout.desktop' defaultMessage='Desktop' /></a>
+          </p>
+        </div>
+      );
+      break;
+    case 'multiple':
+      layoutContent = (
+        <div className='layout__selector'>
+          <p>
+            <FormattedMessage id='layout.current_is' defaultMessage='Your current layout is:' /> <b><FormattedMessage id='layout.desktop' defaultMessage='Desktop' /></b>
+          </p>
+          <p>
+            <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='auto'><FormattedMessage id='layout.auto' defaultMessage='Auto' /></a> • <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='single'><FormattedMessage id='layout.mobile' defaultMessage='Mobile' /></a>
+          </p>
+        </div>
+      );
+      break;
+    default:
+      layoutContent = (
+        <div className='layout__selector'>
+          <p>
+            <FormattedMessage id='layout.current_is' defaultMessage='Your current layout is:' /> <b><FormattedMessage id='layout.auto' defaultMessage='Auto' /></b>
+          </p>
+          <p>
+            <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='multiple'><FormattedMessage id='layout.desktop' defaultMessage='Desktop' /></a> • <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='single'><FormattedMessage id='layout.mobile' defaultMessage='Mobile' /></a>
+          </p>
+        </div>
+      );
+      break;
+    }
+
     return (
       <div className='drawer'>
         {header}
@@ -79,6 +129,9 @@ export default class Compose extends React.PureComponent {
             }
           </Motion>
         </div>
+
+        {layoutContent}
+
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index f8ea01024..ac93b3d47 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -96,8 +96,8 @@ export default class GettingStarted extends ImmutablePureComponent {
             <p>
               <FormattedMessage
                 id='getting_started.open_source_notice'
-                defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
-                values={{ github: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> }}
+                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>
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 9d631644a..bf580794d 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -44,7 +44,7 @@ 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 withDismiss />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse withDismiss />
       </div>
     );
   }
@@ -59,7 +59,7 @@ 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 withDismiss />
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse withDismiss />
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
index b056357a2..dab5e47ea 100644
--- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -30,8 +30,8 @@ const PageOne = ({ acct, domain }) => (
     </div>
 
     <div>
-      <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1>
-      <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p>
+      <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1>
+      <p><FormattedMessage id='onboarding.page_one.federation' 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.' values={{ domain }} /></p>
       <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p>
     </div>
   </div>
@@ -150,8 +150,8 @@ const PageSix = ({ admin, domain }) => {
     <div className='onboarding-modal__page onboarding-modal__page-six'>
       <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1>
       {adminSection}
-      <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p>
-      <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
+      <p><FormattedMessage id='onboarding.page_six.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}.' values={{ domain, fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>, github: <a href='https://github.com/glitch-soc/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p>
+      <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
       <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p>
     </div>
   );
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 08d087da1..4d38c2677 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -72,12 +72,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 = {
@@ -174,12 +179,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 (
-      <div className='ui' ref={this.setRef}>
+      <div className={'ui ' + columnsClass(layout)} ref={this.setRef}>
         <TabsBar />
-        <ColumnsAreaContainer singleColumn={isMobile(width)}>
+        <ColumnsAreaContainer singleColumn={isMobile(width, layout)}>
           <WrappedSwitch>
             <Redirect from='/' to='/getting-started' exact />
             <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
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 ccf2e6303..5ab914477 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -191,6 +191,14 @@
       {
         "defaultMessage": "{name} boosted",
         "id": "status.reblogged_by"
+      },
+      {
+        "defaultMessage": "Collapse",
+        "id": "status.collapse"
+      },
+      {
+        "defaultMessage": "Uncollapse",
+        "id": "status.uncollapse"
       }
     ],
     "path": "app/javascript/mastodon/components/status.json"
@@ -650,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"
@@ -756,7 +780,7 @@
         "id": "getting_started.appsshort"
       },
       {
-        "defaultMessage": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
+        "defaultMessage": "Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.",
         "id": "getting_started.open_source_notice"
       }
     ],
@@ -1045,11 +1069,11 @@
         "id": "column.public"
       },
       {
-        "defaultMessage": "Welcome to Mastodon!",
+        "defaultMessage": "Welcome to {domain}!",
         "id": "onboarding.page_one.welcome"
       },
       {
-        "defaultMessage": "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"
       },
       {
@@ -1097,7 +1121,7 @@
         "id": "onboarding.page_six.almost_done"
       },
       {
-        "defaultMessage": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+        "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}.",
         "id": "onboarding.page_six.github"
       },
       {
@@ -1187,4 +1211,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 253db7110..d0c0ca137 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -71,7 +71,7 @@
   "getting_started.appsshort": "Apps",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Getting started",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
+  "getting_started.open_source_notice": "Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.",
   "getting_started.userguide": "User Guide",
   "home.column_settings.advanced": "Advanced",
   "home.column_settings.basic": "Basic",
@@ -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",
@@ -111,14 +115,14 @@
   "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
   "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
   "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
-  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.federation": "{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.",
   "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
-  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_one.welcome": "Welcome to {domain}!",
   "onboarding.page_six.admin": "Your instance's admin is {admin}.",
   "onboarding.page_six.almost_done": "Almost done...",
   "onboarding.page_six.appetoot": "Bon Appetoot!",
   "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
-  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.",
   "onboarding.page_six.guidelines": "community guidelines",
   "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
   "onboarding.page_six.various_app": "mobile apps",
@@ -143,6 +147,7 @@
   "search.placeholder": "Search",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "status.cannot_reblog": "This post cannot be boosted",
+  "status.collapse": "Collapse",
   "status.delete": "Delete",
   "status.favourite": "Favourite",
   "status.load_more": "Load more",
@@ -159,6 +164,7 @@
   "status.sensitive_warning": "Sensitive content",
   "status.show_less": "Show less",
   "status.show_more": "Show more",
+  "status.uncollapse": "Uncollapse",
   "status.unmute_conversation": "Unmute conversation",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
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/packs/custom.js b/app/javascript/packs/custom.js
new file mode 100644
index 000000000..4db2964f6
--- /dev/null
+++ b/app/javascript/packs/custom.js
@@ -0,0 +1 @@
+require('../styles/custom.scss');
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index a0e511b0a..ae903cdd3 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -4,6 +4,7 @@ import { delegate } from 'rails-ujs';
 import emojify from '../mastodon/emoji';
 import { getLocale } from '../mastodon/locales';
 import loadPolyfills from '../mastodon/load_polyfills';
+import { processBio } from '../mastodon/features/account/util/bio_metadata';
 
 require.context('../images/', true);
 
@@ -87,7 +88,8 @@ function main() {
   delegate(document, '.account_note', 'input', ({ target }) => {
     const noteCounter = document.querySelector('.note-counter');
     if (noteCounter) {
-      noteCounter.textContent = 160 - length(target.value);
+      const noteWithoutMetadata = processBio(target.value).text;
+      noteCounter.textContent = 500 - length(noteWithoutMetadata);
     }
   });
 }
diff --git a/app/javascript/styles/_mixins.scss b/app/javascript/styles/_mixins.scss
index 67d768a6c..7412991b8 100644
--- a/app/javascript/styles/_mixins.scss
+++ b/app/javascript/styles/_mixins.scss
@@ -1,5 +1,5 @@
 @mixin avatar-radius() {
-  border-radius: 4px;
+  border-radius: $ui-avatar-border-size;
   background: transparent no-repeat;
   background-position: 50%;
   background-clip: padding-box;
@@ -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/about.scss b/app/javascript/styles/about.scss
index 3512bdcb4..7145d0092 100644
--- a/app/javascript/styles/about.scss
+++ b/app/javascript/styles/about.scss
@@ -172,16 +172,14 @@
   text-align: center;
 
   .avatar {
-    width: 80px;
-    height: 80px;
+    @include avatar-size(80px);
     margin: 0 auto;
     margin-bottom: 15px;
 
     img {
+      @include avatar-radius();
+      @include avatar-size(80px);
       display: block;
-      width: 80px;
-      height: 80px;
-      border-radius: 48px;
     }
   }
 
diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss
index 801817d80..10f8bd2b9 100644
--- a/app/javascript/styles/accounts.scss
+++ b/app/javascript/styles/accounts.scss
@@ -46,17 +46,16 @@
   }
 
   .avatar {
-    width: 120px;
+    @include avatar-size(120px);
     margin: 0 auto;
     margin-bottom: 15px;
     position: relative;
     z-index: 2;
 
     img {
-      width: 120px;
-      height: 120px;
+      @include avatar-radius();
+      @include avatar-size(120px);
       display: block;
-      border-radius: 120px;
     }
   }
 
@@ -283,16 +282,14 @@
     }
 
     .avatar {
-      width: 60px;
-      height: 60px;
+      @include avatar-size(60px);
       float: left;
       margin-right: 15px;
 
       img {
+        @include avatar-radius();
+        @include avatar-size(60px);
         display: block;
-        width: 60px;
-        height: 60px;
-        border-radius: 60px;
       }
     }
 
@@ -359,15 +356,14 @@
     }
 
     & > div {
+      @include avatar-size(48px);
       float: left;
       margin-right: 10px;
-      width: 48px;
-      height: 48px;
     }
 
     .avatar {
+      @include avatar-radius();
       display: block;
-      border-radius: 4px;
     }
 
     .display-name {
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 88431fc69..a7c982cb2 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -423,11 +423,13 @@
 
 .status__content,
 .reply-indicator__content {
+  position: relative;
   font-size: 15px;
   line-height: 20px;
+  color: $primary-text-color;
   word-wrap: break-word;
   font-weight: 400;
-  overflow: hidden;
+  overflow: visible;
   white-space: pre-wrap;
 
   .emojione {
@@ -470,19 +472,10 @@
     }
   }
 
-  .status__content__spoiler-link {
-    background: lighten($ui-base-color, 30%);
-
-    &:hover {
-      background: lighten($ui-base-color, 33%);
-      text-decoration: none;
-    }
-  }
-
-  .status__content__text {
+  .status__content__spoiler {
     display: none;
 
-    &.status__content__text--visible {
+    &.status__content__spoiler--visible {
       display: block;
     }
   }
@@ -491,15 +484,21 @@
 .status__content__spoiler-link {
   display: inline-block;
   border-radius: 2px;
-  background: transparent;
-  border: 0;
+  background: lighten($ui-base-color, 30%);
+  border: none;
   color: lighten($ui-base-color, 8%);
   font-weight: 500;
   font-size: 11px;
-  padding: 0 6px;
+  padding: 0 5px;
   text-transform: uppercase;
   line-height: inherit;
   cursor: pointer;
+  vertical-align: bottom;
+
+    &:hover {
+      background: lighten($ui-base-color, 33%);
+      text-decoration: none;
+    }
 }
 
 .status__prepend-icon-wrapper {
@@ -511,6 +510,7 @@
   padding: 8px 10px;
   padding-left: 68px;
   position: relative;
+  height: auto;
   min-height: 48px;
   border-bottom: 1px solid lighten($ui-base-color, 8%);
   cursor: default;
@@ -567,6 +567,29 @@
       }
     }
   }
+
+  &.status-collapsed {
+    height: 48px;
+    background-position: center;
+    background-size: cover;
+
+    &::before {
+      display: block;
+      position: absolute;
+      left: 0;
+      right: 0;
+      top: 0;
+      bottom: 0;
+    	background-image: linear-gradient(to bottom, transparentize($ui-base-color, .15), transparentize($ui-base-color, .3) 24px, transparentize($ui-base-color, .35));
+      content: "";
+    }
+
+    .status__content {
+      height: 20px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
 }
 
 .notification-favourite {
@@ -580,9 +603,16 @@
 }
 
 .status__relative-time {
+  display: inline-block;
+  margin-left: auto;
+  padding-left: 18px;
+  width: 120px;
   color: lighten($ui-base-color, 26%);
-  float: right;
   font-size: 14px;
+  text-align: right;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .status__display-name {
@@ -596,7 +626,16 @@
 }
 
 .status__info {
+  margin: 2px 0 0;
   font-size: 15px;
+  line-height: 24px;
+}
+
+.status__info__icons {
+  display: inline-block;
+  position: relative;
+  float: right;
+  color: $ui-primary-color;
 }
 
 .status-check-box {
@@ -637,11 +676,20 @@
   align-items: center;
   display: flex;
   margin-top: 10px;
+  margin-left: -58px;
+
+  &::before {
+    display: block;
+    flex: 1 1 0;
+    max-width: 58px;
+    content: "";
+  }
 }
 
 .status__action-bar-button {
   float: left;
   margin-right: 18px;
+  flex: 0 0 auto;
 }
 
 .status__action-bar-dropdown {
@@ -791,9 +839,12 @@
   padding: 10px;
 }
 
-.account__header {
+.account__header__wrapper {
   flex: 0 0 auto;
   background: lighten($ui-base-color, 4%);
+}
+
+.account__header {
   text-align: center;
   background-size: cover;
   background-position: center;
@@ -858,6 +909,58 @@
   }
 }
 
+.account__metadata {
+  width: 100%;
+  font-size: 15px;
+  line-height: 20px;
+  overflow: hidden;
+  border-collapse: collapse;
+
+  a {
+    text-decoration: none;
+
+    &:hover{
+      text-decoration: underline;
+    }
+  }
+
+  tr {
+    border-top: 1px solid lighten($ui-base-color, 8%);
+  }
+
+   th, td {
+    padding: 14px 20px;
+    vertical-align: middle;
+
+    & > div {
+      max-height: 40px;
+      overflow-y: auto;
+      text-overflow: ellipsis;
+    }
+  }
+
+  th {
+    color: $ui-primary-color;
+    background: lighten($ui-base-color, 13%);
+    font-variant: small-caps;
+    max-width: 120px;
+
+    a {
+      color: $primary-text-color;
+    }
+  }
+
+  td {
+    flex: auto;
+    color: $primary-text-color;
+    background: $ui-base-color;
+
+    a {
+      color: $ui-highlight-color;
+    }
+  }
+}
+
 .account__action-bar {
   border-top: 1px solid lighten($ui-base-color, 8%);
   border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -919,12 +1022,11 @@
 }
 
 .account__header__avatar {
-  background-size: 90px 90px;
+  @include avatar-radius();
+  @include avatar-size(90px);
   display: block;
-  height: 90px;
   margin: 0 auto 10px;
   overflow: hidden;
-  width: 90px;
 }
 
 .account-authorize {
@@ -1078,6 +1180,7 @@
 
 .display-name {
   display: block;
+  position: relative;
   max-width: 100%;
   overflow: hidden;
   text-overflow: ellipsis;
@@ -1246,11 +1349,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;
   }
 }
 
@@ -1260,6 +1364,7 @@
   box-sizing: border-box;
   display: flex;
   flex-direction: column;
+  overflow: hidden;
 
   > .scrollable {
     background: $ui-base-color;
@@ -1280,7 +1385,7 @@
   box-sizing: border-box;
   display: flex;
   flex-direction: column;
-  overflow-y: hidden;
+  overflow-y: auto;
 }
 
 .drawer__tab {
@@ -1298,24 +1403,22 @@
 .column,
 .drawer {
   flex: 1 1 100%;
-  overflow: hidden;
   @supports(display: grid) { // hack to fix Chrome <57
     contain: strict;
   }
 }
 
-@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%;
@@ -1332,7 +1435,7 @@
   }
 }
 
-@media screen and (min-width: 1025px) {
+@include multi-columns('screen and (min-width: 1025px)', $parent: null) {
   .columns-area {
     padding: 0;
   }
@@ -1365,28 +1468,25 @@
 .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%;
   }
 }
 
@@ -1414,11 +1514,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 {
@@ -1446,7 +1567,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;
     }
@@ -1458,7 +1579,7 @@
   }
 }
 
-@media screen and (min-width: 600px) {
+@include limited-single-column('screen and (max-width: 600px)', $parent: null) {
   .tabs-bar__link {
     span {
       display: inline;
@@ -1466,7 +1587,7 @@
   }
 }
 
-@media screen and (min-width: 1025px) {
+@include multi-columns('screen and (min-width: 1025px)', $parent: null) {
   .tabs-bar {
     display: none;
   }
@@ -1655,7 +1776,7 @@
   }
 
   &.hidden-on-mobile {
-    @media screen and (max-width: 1024px) {
+    @include single-column('screen and (max-width: 1024px)') {
       display: none;
     }
   }
@@ -1699,7 +1820,7 @@
     outline: 0;
   }
 
-  @media screen and (max-width: 600px) {
+  @include limited-single-column('screen and (max-width: 600px)') {
     font-size: 16px;
   }
 }
@@ -1716,7 +1837,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;
   }
@@ -1829,7 +1950,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;
   }
 
@@ -2043,7 +2164,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;
     }
   }
@@ -2740,6 +2861,7 @@ button.icon-button.active i.fa-retweet {
 
 .search {
   position: relative;
+  margin-bottom: 10px;
 }
 
 .search__input {
@@ -2772,7 +2894,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
new file mode 100644
index 000000000..5144e4fb6
--- /dev/null
+++ b/app/javascript/styles/custom.scss
@@ -0,0 +1,118 @@
+@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%);
+  }
+
+  .status__display-name strong {
+    color: lighten($ui-base-color, 35%);
+  }
+}
+
+.status time:after,
+.detailed-status__datetime span:after {
+  font: normal normal normal 14px/1 FontAwesome;
+  content: "\00a0\00a0\f08e";
+}
+
+.compose-form__buttons button.active:last-child {
+  color:$ui-secondary-color;
+  background-color: $ui-highlight-color;
+  border-radius:3px;
+}
+
+.about-body .mascot {
+  display:none;
+}
+
+.screenshot-with-signup {
+  min-height:300px;
+}
+
+.screenshot-with-signup .closed-registrations-message,
+.screenshot-with-signup form {
+  background-color: rgba(0,0,0,0.7);
+  margin:auto;
+}
+
+.screenshot-with-signup .closed-registrations-message .clock {
+  font-size:150%;
+}
+
+.drawer .drawer__inner {
+  overflow: visible;
+}
+
+.column {
+  // trying to fix @mdhughes safari problem
+  max-height:100vh;
+}
+
+
+
+.media-gallery {
+  height:auto !important;
+  max-height:250px;
+  position:relative;
+  margin-top:20px;
+  margin-left:-68px;
+  width: calc(100% + 80px);
+}
+
+.media-gallery:before{
+  content: "";
+  display: block;
+  padding-top: 100%;
+}
+
+.media-gallery__item,
+.media-gallery .media-spoiler{
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0 !important;
+  position:absolute;
+}
+
+.media-spoiler-video:before {
+  content:"";
+  display:block;
+  padding-top:100%;
+}
+
+.media-spoiler-video,
+.status__video-player,
+.detailed-status > .media-spoiler,
+.status > .media-spoiler {
+  height:auto !important;
+  max-height:250px;
+  position:relative;
+  margin-top:20px;
+  margin-left:-68px;
+  width: calc(100% + 80px) !important;
+}
+
+.status__video-player-video {
+  transform:unset;
+}
+
+.detailed-status > .media-spoiler,
+.status > .media-spoiler {
+  height:250px !important;
+  vertical-align:middle;
+}
diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss
index fcec32d44..490e36fab 100644
--- a/app/javascript/styles/stream_entries.scss
+++ b/app/javascript/styles/stream_entries.scss
@@ -64,19 +64,17 @@
 
     .status__avatar {
       position: absolute;
+      @include avatar-size(48px);
       left: 14px;
       top: 14px;
-      width: 48px;
-      height: 48px;
 
       & > div {
-        width: 48px;
-        height: 48px;
+        @include avatar-size(48px);
       }
 
       img {
+        @include avatar-radius();
         display: block;
-        border-radius: 4px;
       }
     }
 
@@ -164,12 +162,11 @@
     }
 
     .avatar {
-      width: 48px;
-      height: 48px;
+      @include avatar-size(48px);
 
       img {
+        @include avatar-radius();
         display: block;
-        border-radius: 4px;
       }
     }
 
diff --git a/app/javascript/styles/variables.scss b/app/javascript/styles/variables.scss
index 8362096e1..bf8c12bc0 100644
--- a/app/javascript/styles/variables.scss
+++ b/app/javascript/styles/variables.scss
@@ -26,3 +26,6 @@ $ui-base-color: $classic-base-color !default;             // Darkest
 $ui-primary-color: $classic-primary-color !default;       // Lighter
 $ui-secondary-color: $classic-secondary-color !default;   // Lightest
 $ui-highlight-color: $classic-highlight-color !default;   // Vibrant
+
+// Avatar border size (8% default, 100% for rounded avatars)
+$ui-avatar-border-size: 8%;
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 90a1441f2..1885eff26 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -95,6 +95,12 @@ class FeedManager
   end
 
   def filter_from_home?(status, receiver_id)
+    # extremely violent filtering code BEGIN
+    #filter_string = 'e'
+    #reggie = Regexp.new(filter_string)
+    #return true if reggie === status.content || reggie === status.spoiler_text
+    # extremely violent filtering code END
+
     return true if status.reply? && status.in_reply_to_id.nil?
 
     check_for_mutes = [status.account_id]
diff --git a/app/models/account.rb b/app/models/account.rb
index 2b54cee5f..49d2c88f6 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -60,7 +60,7 @@ class Account < ApplicationRecord
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
   validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
-  validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
+  validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? }
 
   # Timelines
   has_many :stream_entries, inverse_of: :account, dependent: :destroy
@@ -251,6 +251,22 @@ class Account < ApplicationRecord
     self.public_key  = keypair.public_key.to_pem
   end
 
+  YAML_START = "---\r\n"
+  YAML_END = "\r\n...\r\n"
+
+  def note_length_does_not_exceed_length_limit
+    note_without_metadata = note
+    if note.start_with? YAML_START
+      idx = note.index YAML_END
+      unless idx.nil?
+        note_without_metadata = note[(idx + YAML_END.length) .. -1]
+      end
+    end
+    if note_without_metadata.mb_chars.grapheme_length > 500
+      errors.add(:note, "can't be longer than 500 graphemes")
+    end
+  end
+
   def normalize_domain
     return if local?
 
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 2e6fbb5c3..ae9b63abe 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -36,7 +36,11 @@ class PostStatusService < BaseService
 
     LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
     DistributionWorker.perform_async(status.id)
-    Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+
+    # match both with and without U+FE0F (the emoji variation selector)
+    unless /[👁👁️]$/.match?(status.content)
+      Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
+    end
 
     if options[:idempotency].present?
       redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id)
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index ba24b1f9d..497cdb4f5 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -20,7 +20,10 @@ class ReblogService < BaseService
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
     DistributionWorker.perform_async(reblog.id)
-    Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
+    unless /👁$/.match?(reblogged_status.content)
+      Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id)
+    end
+
 
     if reblogged_status.local?
       NotifyService.new.call(reblog.reblog.account, reblog)
diff --git a/app/validators/status_length_validator.rb b/app/validators/status_length_validator.rb
index 3f3e422d9..cd791e2f3 100644
--- a/app/validators/status_length_validator.rb
+++ b/app/validators/status_length_validator.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class StatusLengthValidator < ActiveModel::Validator
-  MAX_CHARS = 500
+  MAX_CHARS = 512
 
   def validate(status)
     return unless status.local? && !status.reblog?
diff --git a/app/views/about/_links.html.haml b/app/views/about/_links.html.haml
index fb3350539..d7fe317e6 100644
--- a/app/views/about/_links.html.haml
+++ b/app/views/about/_links.html.haml
@@ -9,4 +9,4 @@
           %li= link_to t('about.get_started'), new_user_registration_path
         %li= link_to t('auth.login'), new_user_session_path
       %li= link_to t('about.terms'), terms_path
-      %li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
+      %li= link_to t('about.source_code'), 'https://github.com/chronister/mastodon'
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index d15b04163..2a7f8c752 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -36,7 +36,7 @@
         .info
           = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn'
           ·
-          = link_to t('about.other_instances'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md'
+          = link_to t('about.other_instances'), 'https://instances.mastodon.xyz/'
           ·
           = link_to t('about.about_this'), about_more_path
 
@@ -82,6 +82,6 @@
       ·
       = link_to t('about.apps'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md'
       ·
-      = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
+      = link_to t('about.source_code'), 'https://github.com/chronister/mastodon'
       ·
-      = link_to t('about.other_instances'), 'https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md'
+      = link_to t('about.other_instances'), 'https://instances.mastodon.xyz/'
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 2b846006f..8dc61fec9 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -6,7 +6,7 @@
 
   .fields-group
     = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe
-    = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 160 - @account.note.size).html_safe
+    = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 500 - @account.note.size).html_safe
     = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar')
     = f.input :header, wrapper: :with_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header')