about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/components/emoji.jsx34
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx51
-rw-r--r--app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx2
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx2
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx54
-rw-r--r--app/assets/javascripts/extras.jsx13
-rw-r--r--app/assets/stylesheets/components.scss11
-rw-r--r--app/assets/stylesheets/stream_entries.scss18
-rw-r--r--app/controllers/api/v1/accounts_controller.rb10
-rw-r--r--app/controllers/api/v1/blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/follow_requests_controller.rb2
-rw-r--r--app/controllers/api/v1/mutes_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb4
-rw-r--r--app/controllers/api/v1/statuses_controller.rb6
-rw-r--r--app/controllers/api/v1/timelines_controller.rb12
-rw-r--r--app/controllers/settings/imports_controller.rb34
-rw-r--r--app/controllers/xrd_controller.rb11
-rw-r--r--app/lib/formatter.rb16
-rw-r--r--app/lib/tag_manager.rb6
-rw-r--r--app/models/favourite.rb2
-rw-r--r--app/models/follow.rb4
-rw-r--r--app/models/import.rb14
-rw-r--r--app/models/status.rb4
-rw-r--r--app/views/accounts/_header.html.haml6
-rw-r--r--app/views/admin/accounts/show.html.haml6
-rw-r--r--app/views/api/v1/accounts/show.rabl8
-rw-r--r--app/views/api/v1/statuses/_show.rabl4
-rw-r--r--app/views/layouts/admin.html.haml9
-rw-r--r--app/views/settings/imports/show.html.haml11
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml10
-rw-r--r--app/views/stream_entries/_simple_status.html.haml6
-rw-r--r--app/workers/import_worker.rb54
33 files changed, 352 insertions, 78 deletions
diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx
index c93c07c74..eee657b86 100644
--- a/app/assets/javascripts/components/emoji.jsx
+++ b/app/assets/javascripts/components/emoji.jsx
@@ -1,9 +1,35 @@
 import emojione from 'emojione';
 
-emojione.imageType    = 'png';
-emojione.sprites      = false;
-emojione.imagePathPNG = '/emoji/';
+const toImage = str => shortnameToImage(unicodeToImage(str));
+
+const unicodeToImage = str => {
+  const mappedUnicode = emojione.mapUnicodeToShort();
+
+  return str.replace(emojione.regUnicode, unicodeChar => {
+    if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) {
+      return unicodeChar;
+    }
+
+    const unicode  = emojione.jsEscapeMap[unicodeChar];
+    const short    = mappedUnicode[unicode];
+    const filename = emojione.emojioneList[short].fname;
+    const alt      = emojione.convert(unicode.toUpperCase());
+
+    return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
+  });
+};
+
+const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => {
+  if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) {
+    return shortname;
+  }
+
+  const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
+  const alt     = emojione.convert(unicode.toUpperCase());
+
+  return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`;
+});
 
 export default function emojify(text) {
-  return emojione.toImage(text);
+  return toImage(text);
 };
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index e1aae3c77..a359963c4 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -4,6 +4,7 @@ import emojify from '../../../emoji';
 import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -11,6 +12,47 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
 });
 
+const Avatar = React.createClass({
+
+  propTypes: {
+    account: ImmutablePropTypes.map.isRequired
+  },
+
+  getInitialState () {
+    return {
+      isHovered: false
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleMouseOver () {
+    if (this.state.isHovered) return;
+    this.setState({ isHovered: true });
+  },
+
+  handleMouseOut () {
+    if (!this.state.isHovered) return;
+    this.setState({ isHovered: false });
+  },
+
+  render () {
+    const { account }   = this.props;
+    const { isHovered } = this.state;
+
+    return (
+      <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
+        {({ radius }) =>
+          <a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
+            <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
+          </a>
+        }
+      </Motion>
+    );
+  }
+
+});
+
 const Header = React.createClass({
 
   propTypes: {
@@ -68,14 +110,9 @@ const Header = React.createClass({
     return (
       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
         <div style={{ padding: '20px 10px' }}>
-          <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
-            <div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
-              <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
-            </div>
-
-            <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
-          </a>
+          <Avatar account={account} />
 
+          <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
           <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
           <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
 
diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
index 471f0e1bb..1920b29bf 100644
--- a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
+++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
@@ -43,7 +43,7 @@ const EmojiPickerDropdown = React.createClass({
     return (
       <Dropdown ref={this.setRef} style={style}>
         <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}>
-          <img className="emojione" alt="🙂" src="/emoji/1f602.png" />
+          <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" />
         </DropdownTrigger>
 
         <DropdownContent className='dropdown__left'>
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 846e4f53b..6f9e988ba 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -41,7 +41,7 @@ const GettingStarted = ({ intl, me }) => {
         <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
       </div>
 
-      <div className='scrollable optionally-scrollable'>
+      <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
         <div className='static-content getting-started'>
           <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
           <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index 10e989b2a..4b7e4bb46 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -36,34 +36,62 @@ const UI = React.createClass({
     this.setState({ width: window.innerWidth });
   },
 
+  handleDragEnter (e) {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    this.setState({ draggingOver: true });
+  },
+
   handleDragOver (e) {
     e.preventDefault();
     e.stopPropagation();
 
-    e.dataTransfer.dropEffect = 'copy';
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
 
-    if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') {
-      this.setState({ draggingOver: true });
     }
+
+    return false;
   },
 
   handleDrop (e) {
     e.preventDefault();
 
+    this.setState({ draggingOver: false });
+
     if (e.dataTransfer && e.dataTransfer.files.length === 1) {
-      this.setState({ draggingOver: false });
       this.props.dispatch(uploadCompose(e.dataTransfer.files));
     }
   },
 
-  handleDragLeave () {
+  handleDragLeave (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
     this.setState({ draggingOver: false });
   },
 
   componentWillMount () {
     window.addEventListener('resize', this.handleResize, { passive: true });
-    window.addEventListener('dragover', this.handleDragOver);
-    window.addEventListener('drop', this.handleDrop);
+    document.addEventListener('dragenter', this.handleDragEnter, false);
+    document.addEventListener('dragover', this.handleDragOver, false);
+    document.addEventListener('drop', this.handleDrop, false);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
 
     this.props.dispatch(refreshTimeline('home'));
     this.props.dispatch(refreshNotifications());
@@ -71,8 +99,14 @@ const UI = React.createClass({
 
   componentWillUnmount () {
     window.removeEventListener('resize', this.handleResize);
-    window.removeEventListener('dragover', this.handleDragOver);
-    window.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+  },
+
+  setRef (c) {
+    this.node = c;
   },
 
   render () {
@@ -99,7 +133,7 @@ const UI = React.createClass({
     }
 
     return (
-      <div className='ui' onDragLeave={this.handleDragLeave}>
+      <div className='ui' ref={this.setRef}>
         <TabsBar />
 
         {mountedColumns}
diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx
index 5738863dd..c13feceff 100644
--- a/app/assets/javascripts/extras.jsx
+++ b/app/assets/javascripts/extras.jsx
@@ -24,4 +24,17 @@ $(() => {
       window.location.href = $(e.target).attr('href');
     }
   });
+
+  $('.status__content__spoiler-link').on('click', e => {
+    e.preventDefault();
+    const contentEl = $(e.target).parent().parent().find('div');
+
+    if (contentEl.is(':visible')) {
+      contentEl.hide();
+      $(e.target).parent().attr('style', 'margin-bottom: 0');
+    } else {
+      contentEl.show();
+      $(e.target).parent().attr('style', null);
+    }
+  });
 });
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 45f93ef90..1c1e8bffc 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -21,7 +21,7 @@
   text-decoration: none;
   transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     background-color: lighten($color4, 7%);
     transition: all 200ms ease-out;
   }
@@ -54,7 +54,7 @@
   cursor: pointer;
   transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     color: lighten($color1, 33%);
     transition: all 200ms ease-out;
   }
@@ -79,7 +79,7 @@
   &.inverted {
     color: lighten($color1, 33%);
 
-    &:hover {
+    &:hover, &:active, &:focus {
       color: lighten($color1, 26%);
     }
 
@@ -105,7 +105,7 @@
   outline: 0;
   transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     color: lighten($color1, 26%);
     transition: all 200ms ease-out;
   }
@@ -771,6 +771,7 @@ a.status__content__spoiler-link {
   padding: 0;
   display: flex;
   flex-direction: column;
+  overflow: hidden;
   overflow-y: auto;
   flex-grow: 1;
 }
@@ -1639,7 +1640,7 @@ button.active i.fa-retweet {
     margin-top: 2px;
   }
 
-  &:hover {
+  &:hover, &:active, &:focus {
     img {
       opacity: 1;
       filter: none;
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index b9a9a1da3..4a6dc6aa4 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -97,6 +97,15 @@
       a {
         color: $color4;
       }
+
+      a.status__content__spoiler-link {
+        color: $color5;
+        background: $color3;
+
+        &:hover {
+          background: lighten($color3, 8%);
+        }
+      }
     }
 
     .status__attachments {
@@ -163,6 +172,15 @@
       a {
         color: $color4;
       }
+
+      a.status__content__spoiler-link {
+        color: $color5;
+        background: $color3;
+
+        &:hover {
+          background: lighten($color3, 8%);
+        }
+      }
     }
 
     .detailed-status__meta {
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index f07450eb0..da18474cb 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -20,7 +20,7 @@ class Api::V1::AccountsController < ApiController
     accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.target_account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = following_api_v1_account_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = following_api_v1_account_url(since_id: results.first.id) unless results.empty?
@@ -35,7 +35,7 @@ class Api::V1::AccountsController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = followers_api_v1_account_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = followers_api_v1_account_url(since_id: results.first.id) unless results.empty?
@@ -52,8 +52,8 @@ class Api::V1::AccountsController < ApiController
     @statuses = cache_collection(@statuses, Status)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -117,7 +117,7 @@ class Api::V1::AccountsController < ApiController
   def search
     @accounts = AccountSearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true', current_account)
 
-    set_account_counters_maps(@accounts) unless @accounts.nil?
+    # set_account_counters_maps(@accounts) unless @accounts.nil?
 
     render action: :index
   end
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index 08aefc175..dadf21265 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -11,7 +11,7 @@ class Api::V1::BlocksController < ApiController
     accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.target_account_id] }.compact
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = api_v1_blocks_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = api_v1_blocks_url(since_id: results.first.id) unless results.empty?
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index ef0a4854a..8a5b81e63 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -11,7 +11,7 @@ class Api::V1::FavouritesController < ApiController
     @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
+    # set_counters_maps(@statuses)
 
     next_path = api_v1_favourites_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.empty?
diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb
index 740083735..3b8e8c078 100644
--- a/app/controllers/api/v1/follow_requests_controller.rb
+++ b/app/controllers/api/v1/follow_requests_controller.rb
@@ -9,7 +9,7 @@ class Api::V1::FollowRequestsController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = api_v1_follow_requests_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
     prev_path = api_v1_follow_requests_url(since_id: results.first.id) unless results.empty?
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index 42a9ed412..6f48de040 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -11,7 +11,7 @@ class Api::V1::MutesController < ApiController
     accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.target_account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = api_v1_mutes_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = api_v1_mutes_url(since_id: results.first.id) unless results.empty?
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 544ba2442..7bbc5419c 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -14,8 +14,8 @@ class Api::V1::NotificationsController < ApiController
     statuses       = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status)
 
     set_maps(statuses)
-    set_counters_maps(statuses)
-    set_account_counters_maps(@notifications.map(&:from_account))
+    # set_counters_maps(statuses)
+    # set_account_counters_maps(@notifications.map(&:from_account))
 
     next_path = api_v1_notifications_url(max_id: @notifications.last.id)    unless @notifications.empty?
     prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty?
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 552f1b1b3..024258c0e 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -23,7 +23,7 @@ class Api::V1::StatusesController < ApiController
     statuses = [@status] + @context[:ancestors] + @context[:descendants]
 
     set_maps(statuses)
-    set_counters_maps(statuses)
+    # set_counters_maps(statuses)
   end
 
   def card
@@ -36,7 +36,7 @@ class Api::V1::StatusesController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |r| accounts[r.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = reblogged_by_api_v1_status_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
@@ -51,7 +51,7 @@ class Api::V1::StatusesController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = favourited_by_api_v1_status_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index af6e5b7df..0446b9e4d 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -11,8 +11,8 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -27,8 +27,8 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -44,8 +44,8 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb
new file mode 100644
index 000000000..cbb5e65da
--- /dev/null
+++ b/app/controllers/settings/imports_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class Settings::ImportsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_account
+
+  def show
+    @import = Import.new
+  end
+
+  def create
+    @import = Import.new(import_params)
+    @import.account = @account
+
+    if @import.save
+      ImportWorker.perform_async(@import.id)
+      redirect_to settings_import_path, notice: I18n.t('imports.success')
+    else
+      render action: :show
+    end
+  end
+
+  private
+
+  def set_account
+    @account = current_user.account
+  end
+
+  def import_params
+    params.require(:import).permit(:data, :type)
+  end
+end
diff --git a/app/controllers/xrd_controller.rb b/app/controllers/xrd_controller.rb
index 9e0277860..6db87cefc 100644
--- a/app/controllers/xrd_controller.rb
+++ b/app/controllers/xrd_controller.rb
@@ -36,11 +36,14 @@ class XrdController < ApplicationController
   end
 
   def username_from_resource
-    if resource_param.start_with?('acct:') || resource_param.include?('@')
-      resource_param.split('@').first.gsub('acct:', '')
+    if resource_param =~ /\Ahttps?:\/\//
+      path_params = Rails.application.routes.recognize_path(resource_param)
+      raise ActiveRecord::RecordNotFound unless path_params[:controller] == 'users' && path_params[:action] == 'show'
+      path_params[:username]
     else
-      url = Addressable::URI.parse(resource_param)
-      url.path.gsub('/users/', '')
+      username, domain = resource_param.gsub(/\Aacct:/, '').split('@')
+      raise ActiveRecord::RecordNotFound unless TagManager.instance.local_domain?(domain)
+      username
     end
   end
 
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index b58952ae0..da7ad2027 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -9,8 +9,6 @@ class Formatter
   include ActionView::Helpers::TextHelper
   include ActionView::Helpers::SanitizeHelper
 
-  AUTOLINK_RE = /https?:\/\/([\S]+\.[!#$&-;=?-[\]_a-z~]|%[\w\d]{2}]+[\w])/i
-
   def format(status)
     return reformat(status.content) unless status.local?
 
@@ -39,6 +37,7 @@ class Formatter
 
     html = encode(account.note)
     html = link_urls(html)
+    html = link_accounts(html)
     html = link_hashtags(html)
 
     html.html_safe # rubocop:disable Rails/OutputSafety
@@ -59,12 +58,23 @@ class Formatter
   def link_mentions(html, mentions)
     html.gsub(Account::MENTION_RE) do |match|
       acct    = Account::MENTION_RE.match(match)[1]
-      mention = mentions.find { |item| item.account.acct.casecmp(acct).zero? }
+      mention = mentions.find { |item| TagManager.instance.same_acct?(item.account.acct, acct) }
 
       mention.nil? ? match : mention_html(match, mention.account)
     end
   end
 
+  def link_accounts(html)
+    html.gsub(Account::MENTION_RE) do |match|
+      acct = Account::MENTION_RE.match(match)[1]
+      username, domain = acct.split('@')
+      domain = nil if TagManager.instance.local_domain?(domain)
+      account = Account.find_remote(username, domain)
+
+      account.nil? ? match : mention_html(match, account)
+    end
+  end
+
   def link_hashtags(html)
     html.gsub(Tag::HASHTAG_RE) do |match|
       hashtag_html(match)
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 34c3edc4b..2a5e7a409 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -60,6 +60,12 @@ class TagManager
     domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.local_domain).zero?
   end
 
+  def same_acct?(canonical, needle)
+    return true if canonical.casecmp(needle).zero?
+    username, domain = needle.split('@')
+    local_domain?(domain) && canonical.casecmp(username).zero?
+  end
+
   def local_url?(url)
     uri    = Addressable::URI.parse(url)
     domain = uri.host + (uri.port ? ":#{uri.port}" : '')
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 67a293888..41d06e734 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -4,7 +4,7 @@ class Favourite < ApplicationRecord
   include Paginable
 
   belongs_to :account, inverse_of: :favourites
-  belongs_to :status,  inverse_of: :favourites
+  belongs_to :status,  inverse_of: :favourites, counter_cache: true
 
   has_one :notification, as: :activity, dependent: :destroy
 
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 57db8c462..8bfe8b2f6 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -3,8 +3,8 @@
 class Follow < ApplicationRecord
   include Paginable
 
-  belongs_to :account
-  belongs_to :target_account, class_name: 'Account'
+  belongs_to :account, counter_cache: :following_count
+  belongs_to :target_account, class_name: 'Account', counter_cache: :followers_count
 
   has_one :notification, as: :activity, dependent: :destroy
 
diff --git a/app/models/import.rb b/app/models/import.rb
new file mode 100644
index 000000000..255063c53
--- /dev/null
+++ b/app/models/import.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Import < ApplicationRecord
+  self.inheritance_column = false
+
+  enum type: [:following, :blocking]
+
+  belongs_to :account
+
+  FILE_TYPES = ['text/plain', 'text/csv'].freeze
+
+  has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV.fetch('PAPERCLIP_SECRET')
+  validates_attachment_content_type :data, content_type: FILE_TYPES
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index d5bbf70fb..81b26fd14 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -10,11 +10,11 @@ class Status < ApplicationRecord
 
   belongs_to :application, class_name: 'Doorkeeper::Application'
 
-  belongs_to :account, inverse_of: :statuses
+  belongs_to :account, inverse_of: :statuses, counter_cache: true
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account'
 
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
-  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
+  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count
 
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index e35b08317..0d43fba30 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -23,12 +23,12 @@
       .counter{ class: active_nav_class(short_account_url(@account)) }
         = link_to short_account_url(@account), class: 'u-url u-uid' do
           %span.counter-label= t('accounts.posts')
-          %span.counter-number= number_with_delimiter @account.statuses.count
+          %span.counter-number= number_with_delimiter @account.statuses_count
       .counter{ class: active_nav_class(following_account_url(@account)) }
         = link_to following_account_url(@account) do
           %span.counter-label= t('accounts.following')
-          %span.counter-number= number_with_delimiter @account.following.count
+          %span.counter-number= number_with_delimiter @account.following_count
       .counter{ class: active_nav_class(followers_account_url(@account)) }
         = link_to followers_account_url(@account) do
           %span.counter-label= t('accounts.followers')
-          %span.counter-number= number_with_delimiter @account.followers.count
+          %span.counter-number= number_with_delimiter @account.followers_count
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index b528e161e..ba1c3bae7 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -47,13 +47,13 @@
 
     %tr
       %th Follows
-      %td= @account.following.count
+      %td= @account.following_count
     %tr
       %th Followers
-      %td= @account.followers.count
+      %td= @account.followers_count
     %tr
       %th Statuses
-      %td= @account.statuses.count
+      %td= @account.statuses_count
     %tr
       %th Media attachments
       %td
diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl
index 151a5080d..32df0457a 100644
--- a/app/views/api/v1/accounts/show.rabl
+++ b/app/views/api/v1/accounts/show.rabl
@@ -1,11 +1,11 @@
 object @account
 
-attributes :id, :username, :acct, :display_name, :locked
+attributes :id, :username, :acct, :display_name, :locked, :created_at
 
 node(:note)            { |account| Formatter.instance.simplified_format(account) }
 node(:url)             { |account| TagManager.instance.url_for(account) }
 node(:avatar)          { |account| full_asset_url(account.avatar.url(:original)) }
 node(:header)          { |account| full_asset_url(account.header.url(:original)) }
-node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) }
-node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) }
-node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : (account.try(:statuses_count)  || account.statuses.count) }
+node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count }
+node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count }
+node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : account.statuses_count }
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index 059e0d13f..54e8a86d8 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }
 node(:url)              { |status| TagManager.instance.url_for(status) }
-node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs.count }
-node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites.count }
+node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs_count }
+node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count }
 
 child :application do
   extends 'api/v1/apps/show'
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index 750d6036f..59fe078df 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -12,6 +12,15 @@
     .content-wrapper
       .content
         %h2= yield :page_title
+
+        - if flash[:notice]
+          .flash-message.notice
+            %strong= flash[:notice]
+
+        - if flash[:alert]
+          .flash-message.alert
+            %strong= flash[:alert]
+
         = yield
 
 = render template: "layouts/application", locals: { body_classes: 'admin' }
diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml
new file mode 100644
index 000000000..8502913dc
--- /dev/null
+++ b/app/views/settings/imports/show.html.haml
@@ -0,0 +1,11 @@
+- content_for :page_title do
+  = t('settings.import')
+
+%p.hint= t('imports.preface')
+
+= simple_form_for @import, url: settings_import_path do |f|
+  = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }
+  = f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data')
+
+  .actions
+    = f.button :button, t('imports.upload'), type: :submit
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 8c0456b1f..8495f28b9 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -9,8 +9,10 @@
 
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
-      %p= status.spoiler_text
-    %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
+      %p{ style: 'margin-bottom: 0' }<
+        %span>= "#{status.spoiler_text} "
+        %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
+    %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     - if status.media_attachments.first.video?
@@ -39,11 +41,11 @@
       ·
     %span<
       = fa_icon('retweet')
-      %span= status.reblogs.count
+      %span= status.reblogs_count
     ·
     %span<
       = fa_icon('star')
-      %span= status.favourites.count
+      %span= status.favourites_count
 
     - if user_signed_in?
       ·
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index cb2c976ce..2eb9bf166 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -14,8 +14,10 @@
 
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
-      %p= status.spoiler_text
-    %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
+      %p{ style: 'margin-bottom: 0' }<
+        %span>= "#{status.spoiler_text} "
+        %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
+    %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     .status__attachments
diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb
new file mode 100644
index 000000000..a3ae2a85a
--- /dev/null
+++ b/app/workers/import_worker.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+class ImportWorker
+  include Sidekiq::Worker
+
+  sidekiq_options retry: false
+
+  def perform(import_id)
+    import = Import.find(import_id)
+
+    case import.type
+    when 'blocking'
+      process_blocks(import)
+    when 'following'
+      process_follows(import)
+    end
+
+    import.destroy
+  end
+
+  private
+
+  def process_blocks(import)
+    from_account = import.account
+
+    CSV.foreach(import.data.path) do |row|
+      next if row.size != 1
+
+      begin
+        target_account = FollowRemoteAccountService.new.call(row[0])
+        next if target_account.nil?
+        BlockService.new.call(from_account, target_account)
+      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
+        next
+      end
+    end
+  end
+
+  def process_follows(import)
+    from_account = import.account
+
+    CSV.foreach(import.data.path) do |row|
+      next if row.size != 1
+
+      begin
+        FollowService.new.call(from_account, row[0])
+      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
+        next
+      end
+    end
+  end
+end