about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/application_controller.rb4
-rw-r--r--app/controllers/auth/passwords_controller.rb15
-rw-r--r--app/controllers/authorize_follows_controller.rb2
-rw-r--r--app/controllers/remote_follow_controller.rb2
-rw-r--r--app/javascript/mastodon/actions/statuses.js17
-rw-r--r--app/javascript/mastodon/components/column.js2
-rw-r--r--app/javascript/mastodon/components/display_name.js7
-rw-r--r--app/javascript/mastodon/components/status.js26
-rw-r--r--app/javascript/mastodon/components/status_content.js6
-rw-r--r--app/javascript/mastodon/components/status_list.js2
-rw-r--r--app/javascript/mastodon/containers/status_container.js6
-rw-r--r--app/javascript/mastodon/emoji.js48
-rw-r--r--app/javascript/mastodon/emojione_light.js2
-rw-r--r--app/javascript/mastodon/features/account/components/header.js13
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js3
-rw-r--r--app/javascript/mastodon/features/follow_requests/components/account_authorize.js3
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js7
-rw-r--r--app/javascript/mastodon/features/report/components/status_check_box.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js14
-rw-r--r--app/javascript/mastodon/features/ui/index.js4
-rw-r--r--app/javascript/mastodon/locales/fa.json4
-rw-r--r--app/javascript/mastodon/locales/fr.json16
-rw-r--r--app/javascript/mastodon/locales/pl.json6
-rw-r--r--app/javascript/mastodon/reducers/accounts.js6
-rw-r--r--app/javascript/mastodon/reducers/statuses.js26
-rw-r--r--app/javascript/mastodon/scroll.js11
-rw-r--r--app/javascript/styles/accounts.scss4
-rw-r--r--app/javascript/styles/basics.scss2
-rw-r--r--app/javascript/styles/compact_header.scss8
-rw-r--r--app/javascript/styles/components.scss4
-rw-r--r--app/javascript/styles/containers.scss56
-rw-r--r--app/javascript/styles/forms.scss2
-rw-r--r--app/lib/formatter.rb2
-rw-r--r--app/models/concerns/account_avatar.rb2
-rw-r--r--app/models/concerns/account_header.rb2
-rw-r--r--app/views/authorize_follows/show.html.haml9
-rw-r--r--app/views/layouts/modal.html.haml16
-rw-r--r--app/views/tags/show.html.haml1
39 files changed, 251 insertions, 114 deletions
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index b3c2db02b..0b40fb05b 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -43,6 +43,10 @@ class ApplicationController < ActionController::Base
     forbidden if current_user.account.suspended?
   end
 
+  def after_sign_out_path_for(_resource_or_scope)
+    new_user_session_path
+  end
+
   protected
 
   def forbidden
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index 54ee1c39c..171b997dc 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -1,5 +1,20 @@
 # frozen_string_literal: true
 
 class Auth::PasswordsController < Devise::PasswordsController
+  before_action :check_validity_of_reset_password_token, only: :edit
+
   layout 'auth'
+
+  private
+
+  def check_validity_of_reset_password_token
+    unless reset_password_token_is_valid?
+      flash[:error] = I18n.t('auth.invalid_reset_password_token')
+      redirect_to new_password_path(resource_name)
+    end
+  end
+
+  def reset_password_token_is_valid?
+    resource_class.with_reset_password_token(params[:reset_password_token]).present?
+  end
 end
diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb
index dccd1c209..78b564183 100644
--- a/app/controllers/authorize_follows_controller.rb
+++ b/app/controllers/authorize_follows_controller.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class AuthorizeFollowsController < ApplicationController
-  layout 'public'
+  layout 'modal'
 
   before_action :authenticate_user!
 
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index 2988231b1..48b026aa5 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class RemoteFollowController < ApplicationController
-  layout 'public'
+  layout 'modal'
 
   before_action :set_account
   before_action :gone, if: :suspended_account?
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 2204e0b14..0b5e72c17 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -23,6 +23,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
 export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
 export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
 
+export const STATUS_SET_HEIGHT = 'STATUS_SET_HEIGHT';
+export const STATUSES_CLEAR_HEIGHT = 'STATUSES_CLEAR_HEIGHT';
+
 export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
@@ -215,3 +218,17 @@ export function unmuteStatusFail(id, error) {
     error,
   };
 };
+
+export function setStatusHeight (id, height) {
+  return {
+    type: STATUS_SET_HEIGHT,
+    id,
+    height,
+  };
+};
+
+export function clearStatusesHeight () {
+  return {
+    type: STATUSES_CLEAR_HEIGHT,
+  };
+};
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
index 29c8f4389..103fcd495 100644
--- a/app/javascript/mastodon/components/column.js
+++ b/app/javascript/mastodon/components/column.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import detectPassiveEvents from 'detect-passive-events';
-import scrollTop from '../scroll';
+import { scrollTop } from '../scroll';
 
 export default class Column extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index dc3665a2b..2cf84f8f4 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -1,7 +1,5 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
-import emojify from '../emoji';
 
 export default class DisplayName extends React.PureComponent {
 
@@ -10,12 +8,11 @@ export default class DisplayName extends React.PureComponent {
   };
 
   render () {
-    const displayName     = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
-    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const displayNameHtml = { __html: this.props.account.get('display_name_html') };
 
     return (
       <span className='display-name'>
-        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
+        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
       </span>
     );
   }
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 9bc3523c8..7468957d3 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -11,8 +11,6 @@ import DisplayName from './display_name';
 import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import { 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';
 import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
@@ -39,6 +37,7 @@ export default class Status extends ImmutablePureComponent {
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
     onBlock: PropTypes.func,
+    onHeightChange: PropTypes.func,
     me: PropTypes.number,
     boostModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
@@ -50,7 +49,6 @@ export default class Status extends ImmutablePureComponent {
 
   state = {
     isExpanded: false,
-    isIntersecting: true, // assume intersecting until told otherwise
     isHidden: false, // set to true in requestIdleCallback to trigger un-render
   }
 
@@ -111,6 +109,10 @@ export default class Status extends ImmutablePureComponent {
     if (this.node && this.node.children.length !== 0) {
       // save the height of the fully-rendered element
       this.height = getRectFromEntry(entry).height;
+
+      if (this.props.onHeightChange) {
+        this.props.onHeightChange(this.props.status, this.height);
+      }
     }
 
     this.setState((prevState) => {
@@ -182,9 +184,13 @@ export default class Status extends ImmutablePureComponent {
       return null;
     }
 
-    if (!isIntersecting && isHidden) {
+    const hasIntersectionObserverWrapper = !!this.props.intersectionObserverWrapper;
+    const isHiddenForSure = isIntersecting === false && isHidden;
+    const visibilityUnknownButHeightIsCached = isIntersecting === undefined && status.has('height');
+
+    if (hasIntersectionObserverWrapper && (isHiddenForSure || visibilityUnknownButHeightIsCached)) {
       return (
-        <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
+        <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height || status.get('height')}px`, opacity: 0, overflow: 'hidden' }}>
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
           {status.get('content')}
         </article>
@@ -192,19 +198,13 @@ export default class Status extends ImmutablePureComponent {
     }
 
     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
-      let displayName = status.getIn(['account', 'display_name']);
-
-      if (displayName.length === 0) {
-        displayName = status.getIn(['account', 'username']);
-      }
-
-      const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+      const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
 
       return (
         <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'>
           <div className='status__prepend'>
             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
-            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
+            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
           </div>
 
           <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} />
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index 5f02e3261..d1381f176 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -3,9 +3,7 @@
 
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
 import PropTypes from 'prop-types';
-import emojify from '../emoji';
 import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
@@ -122,8 +120,8 @@ export default class StatusContent extends React.PureComponent {
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
 
-    const content = { __html: emojify(status.get('content')) };
-    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const content = { __html: status.get('contentHtml') };
+    const spoilerContent = { __html: status.get('spoilerHtml') };
     const directionStyle = { direction: 'ltr' };
     const classNames = classnames('status__content', {
       'status__content--with-action': this.props.onClick && this.context.router,
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 639c8b4e7..271cf33b7 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -105,7 +105,7 @@ export default class StatusList extends ImmutablePureComponent {
   }
 
   handleKeyDown = (e) => {
-    if (['PageDown', 'PageUp', 'End', 'Home'].includes(e.key)) {
+    if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
       const article = (() => {
         switch (e.key) {
         case 'PageDown':
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 9b7f984e0..d71584267 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -19,7 +19,7 @@ import {
   blockAccount,
   muteAccount,
 } from '../actions/accounts';
-import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses';
 import { initReport } from '../actions/reports';
 import { openModal } from '../actions/modal';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -127,6 +127,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onHeightChange (status, height) {
+    dispatch(setStatusHeight(status.get('id'), height));
+  },
+
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js
index 5695c86dd..a41dfdd1d 100644
--- a/app/javascript/mastodon/emoji.js
+++ b/app/javascript/mastodon/emoji.js
@@ -3,34 +3,28 @@ import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
-const excluded = ['™', '©', '®'];
-
-function emojify(str) {
-  // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
-  // and replacing valid unicode strings
-  // that _aren't_ within tags with an <img> version.
-  // The goal is to be the same as an emojione.regUnicode replacement, but faster.
-  let i = -1;
-  let insideTag = false;
-  let match;
-  while (++i < str.length) {
-    const char = str.charAt(i);
-    if (insideTag && char === '>') {
-      insideTag = false;
-    } else if (char === '<') {
-      insideTag = true;
-    } else if (!insideTag && (match = trie.search(str.substring(i)))) {
-      const unicodeStr = match;
-      if (unicodeStr in unicodeMapping && excluded.indexOf(unicodeStr) === -1) {
-        const [filename, shortCode] = unicodeMapping[unicodeStr];
-        const alt      = unicodeStr;
-        const replacement =  `<img draggable="false" class="emojione" alt="${alt}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
-        str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length);
-        i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string
-      }
+const emojify = str => {
+  let rtn = '';
+  for (;;) {
+    let match, i = 0;
+    while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) {
+      i += str.codePointAt(i) < 65536 ? 1 : 2;
+    }
+    if (i === str.length)
+      break;
+    else if (str[i] === '<') {
+      let tagend = str.indexOf('>', i + 1) + 1;
+      if (!tagend)
+        break;
+      rtn += str.slice(0, tagend);
+      str = str.slice(tagend);
+    } else {
+      const [filename, shortCode] = unicodeMapping[match];
+      rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
+      str = str.slice(i + match.length);
     }
   }
-  return str;
-}
+  return rtn + str;
+};
 
 export default emojify;
diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js
index 985e9dbcb..0d07d012f 100644
--- a/app/javascript/mastodon/emojione_light.js
+++ b/app/javascript/mastodon/emojione_light.js
@@ -4,8 +4,10 @@
 const emojione = require('emojione');
 
 const mappedUnicode = emojione.mapUnicodeToShort();
+const excluded = ['®', '©', '™'];
 
 module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap)
+  .filter(c => !excluded.includes(c))
   .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]])
   .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] }))
   .reduce((x, y) => Object.assign(x, y), { });
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 9d7bc82c0..320e669a2 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -4,8 +4,6 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-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';
@@ -95,15 +93,10 @@ export default class Header extends ImmutablePureComponent {
       return null;
     }
 
-    let displayName = account.get('display_name');
     let info        = '';
     let actionBtn   = '';
     let lockedIcon  = '';
 
-    if (displayName.length === 0) {
-      displayName = account.get('username');
-    }
-
     if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
       info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
     }
@@ -128,15 +121,15 @@ 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 content         = { __html: account.get('note_emojified') };
+    const displayNameHtml = { __html: account.get('display_name_html') };
 
     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__display-name' dangerouslySetInnerHTML={displayNameHtml} />
           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
           <div className='account__header__content' dangerouslySetInnerHTML={content} />
 
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
index 35a9b4b1b..7672440b4 100644
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.js
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import Avatar from '../../../components/avatar';
 import IconButton from '../../../components/icon_button';
 import DisplayName from '../../../components/display_name';
-import emojify from '../../../emoji';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -43,7 +42,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
       return null;
     }
 
-    const content  = { __html: emojify(status.get('content')) };
+    const content  = { __html: status.get('contentHtml') };
 
     return (
       <div className='reply-indicator'>
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
index 66fa5c235..4fc5638d9 100644
--- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
@@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from '../../../components/permalink';
 import Avatar from '../../../components/avatar';
 import DisplayName from '../../../components/display_name';
-import emojify from '../../../emoji';
 import IconButton from '../../../components/icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -26,7 +25,7 @@ export default class AccountAuthorize extends ImmutablePureComponent {
 
   render () {
     const { intl, account, onAuthorize, onReject } = this.props;
-    const content = { __html: emojify(account.get('note')) };
+    const content = { __html: account.get('note_emojified') };
 
     return (
       <div className='account-authorize__wrapper'>
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 0771849c2..7d521e4b6 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -7,8 +7,6 @@ import StatusContainer from '../../../containers/status_container';
 import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
-import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 export default class Notification extends ImmutablePureComponent {
@@ -70,9 +68,8 @@ export default class Notification extends ImmutablePureComponent {
   render () {
     const { notification } = this.props;
     const account          = notification.get('account');
-    const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
-    const displayNameHTML  = { __html: emojify(escapeTextContentForBrowser(displayName)) };
-    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
+    const displayNameHtml  = { __html: account.get('display_name_html') };
+    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} />;
 
     switch(notification.get('type')) {
     case 'follow':
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
index 6a1a84c28..cc9232201 100644
--- a/app/javascript/mastodon/features/report/components/status_check_box.js
+++ b/app/javascript/mastodon/features/report/components/status_check_box.js
@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import emojify from '../../../emoji';
 import Toggle from 'react-toggle';
 
 export default class StatusCheckBox extends React.PureComponent {
@@ -15,7 +14,7 @@ export default class StatusCheckBox extends React.PureComponent {
 
   render () {
     const { status, checked, onToggle, disabled } = this.props;
-    const content = { __html: emojify(status.get('content')) };
+    const content = { __html: status.get('contentHtml') };
 
     if (status.get('reblog')) {
       return null;
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
index aea102aac..9031c16fc 100644
--- a/app/javascript/mastodon/features/ui/components/column.js
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ColumnHeader from './column_header';
 import PropTypes from 'prop-types';
 import { debounce } from 'lodash';
-import scrollTop from '../../../scroll';
+import { scrollTop } from '../../../scroll';
 import { isMobile } from '../../../is_mobile';
 
 export default class Column extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 63bd1b021..47d5a2e20 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -12,6 +12,8 @@ import ColumnLoading from './column_loading';
 import BundleColumnError from './bundle_column_error';
 import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
 
+import { scrollRight } from '../../../scroll';
+
 const componentMap = {
   'COMPOSE': Compose,
   'HOME': HomeTimeline,
@@ -49,9 +51,13 @@ export default class ColumnsArea extends ImmutablePureComponent {
     this.setState({ shouldAnimate: true });
   }
 
-  componentDidUpdate() {
+  componentDidUpdate(prevProps) {
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
+
+    if (this.props.children !== prevProps.children && !this.props.singleColumn) {
+      scrollRight(this.node);
+    }
   }
 
   handleSwipe = (index) => {
@@ -74,6 +80,10 @@ export default class ColumnsArea extends ImmutablePureComponent {
     }
   }
 
+  setRef = (node) => {
+    this.node = node;
+  }
+
   renderView = (link, index) => {
     const columnIndex = getIndex(this.context.router.history.location.pathname);
     const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
@@ -114,7 +124,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
     }
 
     return (
-      <div className='columns-area'>
+      <div className='columns-area' ref={this.setRef}>
         {columns.map(column => {
           const params = column.get('params', null) === null ? null : column.get('params').toJS();
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index f7a6eb319..6d53f474d 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -12,6 +12,7 @@ import { debounce } from 'lodash';
 import { uploadCompose } from '../../actions/compose';
 import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
+import { clearStatusesHeight } from '../../actions/statuses';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
@@ -72,6 +73,9 @@ export default class UI extends React.PureComponent {
   };
 
   handleResize = debounce(() => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.dispatch(clearStatusesHeight());
+
     this.setState({ width: window.innerWidth });
   }, 500, {
     trailing: true,
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index d2682ef12..5ada62f93 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -1,7 +1,7 @@
 {
   "account.block": "مسدودسازی @{name}",
   "account.block_domain": "پنهان‌سازی همه چیز از سرور {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.disclaimer_full": "اطلاعات زیر ممکن است نمایهٔ این کاربر را به تمامی نشان ندهد.",
   "account.edit_profile": "ویرایش نمایه",
   "account.follow": "پی بگیرید",
   "account.followers": "پیگیران",
@@ -13,7 +13,7 @@
   "account.posts": "نوشته‌ها",
   "account.report": "گزارش @{name}",
   "account.requested": "در انتظار پذیرش",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "هم‌رسانی نمایهٔ @{name}",
   "account.unblock": "رفع انسداد @{name}",
   "account.unblock_domain": "رفع پنهان‌سازی از {domain}",
   "account.unfollow": "پایان پیگیری",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index ad9060d25..f3f0d0463 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -13,7 +13,7 @@
   "account.posts": "Statuts",
   "account.report": "Signaler",
   "account.requested": "Invitation envoyée",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "Partager le profil de @{name}",
   "account.unblock": "Débloquer",
   "account.unblock_domain": "Ne plus masquer {domain}",
   "account.unfollow": "Ne plus suivre",
@@ -35,11 +35,11 @@
   "column.notifications": "Notifications",
   "column.public": "Fil public global",
   "column_back_button.label": "Retour",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
+  "column_header.hide_settings": "Masquer les paramètres",
+  "column_header.moveLeft_settings": "Déplacer la colonne vers la gauche",
+  "column_header.moveRight_settings": "Déplacer la colonne vers la droite",
   "column_header.pin": "Épingler",
-  "column_header.show_settings": "Show settings",
+  "column_header.show_settings": "Afficher les paramètres",
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
@@ -94,8 +94,8 @@
   "home.column_settings.show_replies": "Afficher les réponses",
   "home.settings": "Paramètres de la colonne",
   "lightbox.close": "Fermer",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "Suivant",
+  "lightbox.previous": "Précédent",
   "loading_indicator.label": "Chargement…",
   "media_gallery.toggle_visible": "Modifier la visibilité",
   "missing_indicator.label": "Non trouvé",
@@ -175,7 +175,7 @@
   "status.report": "Signaler @{name}",
   "status.sensitive_toggle": "Cliquer pour afficher",
   "status.sensitive_warning": "Contenu sensible",
-  "status.share": "Share",
+  "status.share": "Partager",
   "status.show_less": "Replier",
   "status.show_more": "Déplier",
   "status.unmute_conversation": "Ne plus masquer la conversation",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index c42721f64..542230f11 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -73,7 +73,7 @@
   "emoji_button.search": "Szukaj...",
   "emoji_button.symbols": "Symbole",
   "emoji_button.travel": "Podróże i miejsca",
-  "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
+  "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
   "empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
   "empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
   "empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
@@ -159,7 +159,7 @@
   "report.target": "Zgłaszanie {target}",
   "search.placeholder": "Szukaj",
   "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
-  "standalone.public_title": "Spojrzenie wgłąb…",
+  "standalone.public_title": "Spojrzenie w głąb…",
   "status.cannot_reblog": "Ten post nie może zostać podbity",
   "status.delete": "Usuń",
   "status.favourite": "Ulubione",
@@ -178,7 +178,7 @@
   "status.share": "Udostępnij",
   "status.show_less": "Pokaż mniej",
   "status.show_more": "Pokaż więcej",
-  "status.unmute_conversation": "Cofnij wyciezenie konwersacji",
+  "status.unmute_conversation": "Cofnij wyciszenie konwersacji",
   "tabs_bar.compose": "Napisz",
   "tabs_bar.federated_timeline": "Globalne",
   "tabs_bar.home": "Strona główna",
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 4d7c3adc9..6442d13be 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -44,7 +44,9 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { STORE_HYDRATE } from '../actions/store';
+import emojify from '../emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
 
 const normalizeAccount = (state, account) => {
   account = { ...account };
@@ -53,6 +55,10 @@ const normalizeAccount = (state, account) => {
   delete account.following_count;
   delete account.statuses_count;
 
+  const displayName = account.display_name.length === 0 ? account.username : account.display_name;
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
+  account.note_emojified = emojify(account.note);
+
   return state.set(account.id, fromJS(account));
 };
 
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index b1b1d0988..3e40b0b42 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -13,6 +13,8 @@ import {
   CONTEXT_FETCH_SUCCESS,
   STATUS_MUTE_SUCCESS,
   STATUS_UNMUTE_SUCCESS,
+  STATUS_SET_HEIGHT,
+  STATUSES_CLEAR_HEIGHT,
 } from '../actions/statuses';
 import {
   TIMELINE_REFRESH_SUCCESS,
@@ -33,7 +35,11 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import emojify from '../emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const domParser = new DOMParser();
 
 const normalizeStatus = (state, status) => {
   if (!status) {
@@ -49,7 +55,9 @@ const normalizeStatus = (state, status) => {
   }
 
   const searchContent = [status.spoiler_text, status.content].join(' ').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
-  normalStatus.search_index = new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent;
+  normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+  normalStatus.contentHtml = emojify(normalStatus.content);
+  normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''));
 
   return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
 };
@@ -82,6 +90,18 @@ const filterStatuses = (state, relationship) => {
   return state;
 };
 
+const setHeight = (state, id, height) => {
+  return state.update(id, ImmutableMap(), map => map.set('height', height));
+};
+
+const clearHeights = (state) => {
+  state.forEach(status => {
+    state = state.deleteIn([status.get('id'), 'height']);
+  });
+
+  return state;
+};
+
 const initialState = ImmutableMap();
 
 export default function statuses(state = initialState, action) {
@@ -120,6 +140,10 @@ export default function statuses(state = initialState, action) {
     return deleteStatus(state, action.id, action.references);
   case ACCOUNT_BLOCK_SUCCESS:
     return filterStatuses(state, action.relationship);
+  case STATUS_SET_HEIGHT:
+    return setHeight(state, action.id, action.height);
+  case STATUSES_CLEAR_HEIGHT:
+    return clearHeights(state);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
index c089d37db..44f95b17f 100644
--- a/app/javascript/mastodon/scroll.js
+++ b/app/javascript/mastodon/scroll.js
@@ -1,9 +1,9 @@
 const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
 
-const scrollTop = (node) => {
+const scroll = (node, key, target) => {
   const startTime = Date.now();
-  const offset    = node.scrollTop;
-  const targetY   = -offset;
+  const offset    = node[key];
+  const gap       = target - offset;
   const duration  = 1000;
   let interrupt   = false;
 
@@ -15,7 +15,7 @@ const scrollTop = (node) => {
       return;
     }
 
-    node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
+    node[key] = easingOutQuint(0, elapsed, offset, gap, duration);
     requestAnimationFrame(step);
   };
 
@@ -26,4 +26,5 @@ const scrollTop = (node) => {
   };
 };
 
-export default scrollTop;
+export const scrollRight = (node) => scroll(node, 'scrollLeft', node.scrollWidth);
+export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss
index 3d5c1a692..5a9105109 100644
--- a/app/javascript/styles/accounts.scss
+++ b/app/javascript/styles/accounts.scss
@@ -7,7 +7,7 @@
   box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
   overflow: hidden;
 
-  @media screen and (max-width: 700px) {
+  @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
   }
@@ -298,7 +298,7 @@
   display: flex;
   flex-wrap: wrap;
 
-  @media screen and (max-width: 700px) {
+  @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
   }
diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss
index 182ea36a4..4e51b555c 100644
--- a/app/javascript/styles/basics.scss
+++ b/app/javascript/styles/basics.scss
@@ -47,7 +47,7 @@ body {
     padding: 0;
   }
 
-  @media screen and (max-width: 360px) {
+  @media screen and (max-width: 400px) {
     padding-bottom: 0;
   }
 }
diff --git a/app/javascript/styles/compact_header.scss b/app/javascript/styles/compact_header.scss
index 27a67135f..cf12fcfec 100644
--- a/app/javascript/styles/compact_header.scss
+++ b/app/javascript/styles/compact_header.scss
@@ -3,9 +3,15 @@
     font-size: 24px;
     line-height: 28px;
     color: $ui-primary-color;
-    overflow: hidden;
     font-weight: 500;
     margin-bottom: 20px;
+    padding: 0 10px;
+    overflow-wrap: break-word;
+
+    @media screen and (max-width: 740px) {
+      text-align: center;
+      padding: 20px 10px 0;
+    }
 
     a {
       color: inherit;
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 41735c7a4..b5efd560f 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -1835,7 +1835,6 @@
   overflow-y: scroll;
   overflow-x: hidden;
   flex: 1 1 auto;
-  backface-visibility: hidden;
   -webkit-overflow-scrolling: touch;
   @supports(display: grid) { // hack to fix Chrome <57
     contain: strict;
@@ -1853,8 +1852,9 @@
   flex: 0 0 auto;
   font-size: 16px;
   border: 0;
-  text-align: start;
+  text-align: unset;
   padding: 15px;
+  margin: 0;
   z-index: 3;
 
   &:hover {
diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss
index 7dcf2c006..536f4e5a1 100644
--- a/app/javascript/styles/containers.scss
+++ b/app/javascript/styles/containers.scss
@@ -3,7 +3,7 @@
   margin: 0 auto;
   margin-top: 40px;
 
-  @media screen and (max-width: 700px) {
+  @media screen and (max-width: 740px) {
     width: 100%;
     margin: 0;
   }
@@ -13,8 +13,9 @@
   margin: 100px auto;
   margin-bottom: 50px;
 
-  @media screen and (max-width: 360px) {
+  @media screen and (max-width: 400px) {
     margin: 30px auto;
+    margin-bottom: 20px;
   }
 
   h1 {
@@ -42,3 +43,54 @@
     }
   }
 }
+
+.account-header {
+  width: 400px;
+  margin: 0 auto;
+  display: flex;
+  font-size: 13px;
+  line-height: 18px;
+  box-sizing: border-box;
+  padding: 20px 0;
+  padding-bottom: 0;
+  margin-bottom: -30px;
+  margin-top: 40px;
+
+  @media screen and (max-width: 400px) {
+    width: 100%;
+    margin: 0;
+    margin-bottom: 10px;
+    padding: 20px;
+    padding-bottom: 0;
+  }
+
+  .avatar {
+    width: 40px;
+    height: 40px;
+    margin-right: 8px;
+
+    img {
+      width: 100%;
+      height: 100%;
+      display: block;
+      margin: 0;
+      border-radius: 4px;
+    }
+  }
+
+  .name {
+    flex: 1 1 auto;
+    color: $ui-secondary-color;
+
+    .username {
+      display: block;
+      font-weight: 500;
+    }
+  }
+
+  .logout-link {
+    display: block;
+    font-size: 32px;
+    line-height: 40px;
+  }
+}
diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss
index cffb6f197..62094e98e 100644
--- a/app/javascript/styles/forms.scss
+++ b/app/javascript/styles/forms.scss
@@ -317,7 +317,7 @@ code {
 }
 
 .flash-message {
-  background: $ui-base-color;
+  background: lighten($ui-base-color, 8%);
   color: $ui-primary-color;
   border-radius: 4px;
   padding: 15px 10px;
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 7b89305ac..cacc0364f 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -104,7 +104,7 @@ class Formatter
     html_attrs     = { target: '_blank', rel: 'nofollow noopener' }
 
     Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), normalized_url, html_attrs)
-  rescue Addressable::URI::InvalidURIError
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     encode(entity[:url])
   end
 
diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb
index a6527a85b..b0ec689a7 100644
--- a/app/models/concerns/account_avatar.rb
+++ b/app/models/concerns/account_avatar.rb
@@ -8,7 +8,7 @@ module AccountAvatar
   class_methods do
     def avatar_styles(file)
       styles = { original: '120x120#' }
-      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles[:static] = { animated: false } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb
index 4ba9212a2..542e25abe 100644
--- a/app/models/concerns/account_header.rb
+++ b/app/models/concerns/account_header.rb
@@ -8,7 +8,7 @@ module AccountHeader
   class_methods do
     def header_styles(file)
       styles = { original: '700x335#' }
-      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles[:static] = { animated: false } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/views/authorize_follows/show.html.haml b/app/views/authorize_follows/show.html.haml
index 3b60df058..f7a8f72d2 100644
--- a/app/views/authorize_follows/show.html.haml
+++ b/app/views/authorize_follows/show.html.haml
@@ -3,10 +3,9 @@
 
 .form-container
   .follow-prompt
-    %h2= t('authorize_follow.prompt_html', self: current_account.username)
-
     = render 'card', account: @account
 
-  = form_tag authorize_follow_path, method: :post, class: 'simple_form' do
-    = hidden_field_tag :acct, @account.acct
-    = button_tag t('authorize_follow.follow'), type: :submit
+  - unless current_account.following?(@account)
+    = form_tag authorize_follow_path, method: :post, class: 'simple_form' do
+      = hidden_field_tag :acct, @account.acct
+      = button_tag t('authorize_follow.follow'), type: :submit
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
new file mode 100644
index 000000000..a819e098d
--- /dev/null
+++ b/app/views/layouts/modal.html.haml
@@ -0,0 +1,16 @@
+- content_for :header_tags do
+  = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
+
+- content_for :content do
+  - if user_signed_in?
+    .account-header
+      .avatar= image_tag current_account.avatar.url(:original)
+      .name
+        = t 'users.signed_in_as'
+        %span.username @#{current_account.local_username_and_domain}
+      = link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do
+        = fa_icon 'sign-out'
+
+  .container= yield
+
+= render template: 'layouts/application'
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 15bf714c2..8cd2f1825 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -4,6 +4,7 @@
 .compact-header
   %h1<
     = link_to site_title, root_path
+    %br
     %small ##{@tag.name}
 
 - if @statuses.empty?