about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/activitypub/inboxes_controller.rb2
-rw-r--r--app/controllers/admin/base_controller.rb7
-rw-r--r--app/controllers/application_controller.rb1
-rw-r--r--app/controllers/auth/registrations_controller.rb4
-rw-r--r--app/controllers/filters_controller.rb5
-rw-r--r--app/controllers/invites_controller.rb5
-rw-r--r--app/controllers/settings/base_controller.rb5
-rw-r--r--app/controllers/settings/follower_domains_controller.rb2
-rw-r--r--app/controllers/settings/sessions_controller.rb5
-rw-r--r--app/javascript/core/settings.js16
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss18
-rw-r--r--app/javascript/mastodon/actions/compose.js2
-rw-r--r--app/javascript/mastodon/components/status.js9
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js12
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversation.js1
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversations_list.js18
-rw-r--r--app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js2
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_mart_search_light.js22
-rw-r--r--app/javascript/mastodon/features/status/components/card.js18
-rw-r--r--app/javascript/mastodon/features/status/containers/card_container.js2
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/statuses.js3
-rw-r--r--app/javascript/packs/public.js1
-rw-r--r--app/javascript/styles/mastodon/components.scss29
-rw-r--r--app/javascript/styles/mastodon/rtl.scss33
-rw-r--r--app/lib/activitypub/activity/create.rb15
-rw-r--r--app/models/account_conversation.rb3
-rw-r--r--app/models/media_attachment.rb6
-rw-r--r--app/models/status.rb10
-rw-r--r--app/models/status_stat.rb8
-rw-r--r--app/serializers/rest/status_serializer.rb2
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb3
-rw-r--r--app/services/fetch_link_card_service.rb13
-rw-r--r--app/services/verify_link_service.rb2
-rw-r--r--app/views/layouts/admin.html.haml2
-rw-r--r--app/views/settings/profiles/show.html.haml8
-rw-r--r--app/views/stream_entries/_simple_status.html.haml1
-rw-r--r--app/workers/activitypub/processing_worker.rb4
39 files changed, 221 insertions, 82 deletions
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
index af51e32d5..8f5e1887e 100644
--- a/app/controllers/activitypub/inboxes_controller.rb
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -36,6 +36,6 @@ class ActivityPub::InboxesController < Api::BaseController
   end
 
   def process_payload
-    ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
+    ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'), @account&.id)
   end
 end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index fc299f74c..f2190ddf9 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -9,6 +9,13 @@ module Admin
 
     before_action :require_staff!
     before_action :set_pack
+    before_action :set_body_classes
+
+    private
+
+    def set_body_classes
+      @body_classes = 'admin'
+    end
 
     def set_pack
       use_pack 'admin'
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index dca6c5a5a..983b116c9 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -201,6 +201,7 @@ class ApplicationController < ActionController::Base
   def respond_with_error(code)
     respond_to do |format|
       format.any  { head code }
+
       format.html do
         set_locale
         use_pack 'error'
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index fcfd1830a..0696dea86 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -9,7 +9,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   before_action :set_pack
   before_action :set_sessions, only: [:edit, :update]
   before_action :set_instance_presenter, only: [:new, :create, :update]
-  before_action :set_body_classes, only: [:new, :create]
+  before_action :set_body_classes, only: [:new, :create, :edit, :update]
 
   def destroy
     not_found
@@ -86,7 +86,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def set_body_classes
-    @body_classes = 'lighter'
+    @body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter'
   end
 
   def set_invite
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
index 0d1200fcc..f1e110d87 100644
--- a/app/controllers/filters_controller.rb
+++ b/app/controllers/filters_controller.rb
@@ -8,6 +8,7 @@ class FiltersController < ApplicationController
   before_action :set_filters, only: :index
   before_action :set_filter, only: [:edit, :update, :destroy]
   before_action :set_pack
+  before_action :set_body_classes
 
   def index
     @filters = current_account.custom_filters
@@ -59,4 +60,8 @@ class FiltersController < ApplicationController
   def resource_params
     params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, :whole_word, context: [])
   end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
 end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 3dc934761..52cddc404 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -7,6 +7,7 @@ class InvitesController < ApplicationController
 
   before_action :authenticate_user!
   before_action :set_pack
+  before_action :set_body_classes
 
   def index
     authorize :invite, :create?
@@ -49,4 +50,8 @@ class InvitesController < ApplicationController
   def resource_params
     params.require(:invite).permit(:max_uses, :expires_in, :autofollow)
   end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
 end
diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb
index 7322d461b..34ef16568 100644
--- a/app/controllers/settings/base_controller.rb
+++ b/app/controllers/settings/base_controller.rb
@@ -5,8 +5,13 @@ class Settings::BaseController < ApplicationController
 
   before_action :authenticate_user!
   before_action :set_pack
+  before_action :set_body_classes
 
   def set_pack
     use_pack 'settings'
   end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
 end
diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb
index 83945df52..8aae379aa 100644
--- a/app/controllers/settings/follower_domains_controller.rb
+++ b/app/controllers/settings/follower_domains_controller.rb
@@ -1,7 +1,5 @@
 # frozen_string_literal: true
 
-require 'sidekiq-bulk'
-
 class Settings::FollowerDomainsController < Settings::BaseController
   def show
     @account = current_account
diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb
index 780ea64b4..f235dd477 100644
--- a/app/controllers/settings/sessions_controller.rb
+++ b/app/controllers/settings/sessions_controller.rb
@@ -3,6 +3,7 @@
 #  Intentionally does not inherit from BaseController
 class Settings::SessionsController < ApplicationController
   before_action :set_session, only: :destroy
+  before_action :set_body_classes
 
   def destroy
     @session.destroy!
@@ -15,4 +16,8 @@ class Settings::SessionsController < ApplicationController
   def set_session
     @session = current_user.session_activations.find(params[:id])
   end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
 end
diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js
index af97c84f9..23a303747 100644
--- a/app/javascript/core/settings.js
+++ b/app/javascript/core/settings.js
@@ -1,30 +1,16 @@
 //  This file will be loaded on settings pages, regardless of theme.
 
-const { length } = require('stringz');
 const { delegate } = require('rails-ujs');
 import emojify from '../mastodon/features/emoji/emoji';
 
 delegate(document, '#account_display_name', 'input', ({ target }) => {
-  const nameCounter = document.querySelector('.name-counter');
-  const name        = document.querySelector('.card .display-name strong');
-
-  if (nameCounter) {
-    nameCounter.textContent = 30 - length(target.value);
-  }
+  const name = document.querySelector('.card .display-name strong');
 
   if (name) {
     name.innerHTML = emojify(target.value);
   }
 });
 
-delegate(document, '#account_note', 'input', ({ target }) => {
-  const noteCounter = document.querySelector('.note-counter');
-
-  if (noteCounter) {
-    noteCounter.textContent = 500 - length(target.value);
-  }
-});
-
 delegate(document, '#account_avatar', 'change', ({ target }) => {
   const avatar = document.querySelector('.card .avatar img');
   const [file] = target.files || [];
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
index 3c775618e..d4618440d 100644
--- a/app/javascript/flavours/glitch/styles/rtl.scss
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -363,4 +363,22 @@ body.rtl {
     margin-right: 15px;
     text-align: right;
   }
+
+  .fa-chevron-left::before {
+    content: "\F054";
+  }
+
+  .fa-chevron-right::before {
+    content: "\F053";
+  }
+
+  .column-back-button__icon {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .column-header__setting-arrows .column-header__setting-btn:last-child {
+    padding-left: 0;
+    padding-right: 10px;
+  }
 }
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index fac8d32a1..86d83122f 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -142,7 +142,7 @@ export function submitCompose(routerHistory) {
         }
       };
 
-      if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0) {
+      if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
         routerHistory.push('/timelines/direct');
       } else if (response.data.visibility !== 'direct') {
         insertIfOnline('home');
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 0b23e51f8..9fa8cc008 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -9,6 +9,7 @@ import DisplayName from './display_name';
 import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import AttachmentList from './attachment_list';
+import Card from '../features/status/components/card';
 import { injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { MediaGallery, Video } from '../features/ui/util/async-components';
@@ -256,6 +257,14 @@ class Status extends ImmutablePureComponent {
           </Bundle>
         );
       }
+    } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
+      media = (
+        <Card
+          onOpenMedia={this.props.onOpenMedia}
+          card={status.get('card')}
+          compact
+        />
+      );
     }
 
     if (otherAccounts) {
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 1b1a4f8d4..4b56c7fdd 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -89,7 +89,7 @@ class ComposeForm extends ImmutablePureComponent {
       return;
     }
 
-    this.props.onSubmit(this.context.router.history);
+    this.props.onSubmit(this.context.router ? this.context.router.history : null);
   }
 
   onSuggestionsClearRequested = () => {
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index 66c93452c..a1e99dcbb 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -44,11 +44,13 @@ class Upload extends ImmutablePureComponent {
     this.props.onSubmit(this.context.router.history);
   }
 
-  handleUndoClick = () => {
+  handleUndoClick = e => {
+    e.stopPropagation();
     this.props.onUndo(this.props.media.get('id'));
   }
 
-  handleFocalPointClick = () => {
+  handleFocalPointClick = e => {
+    e.stopPropagation();
     this.props.onOpenFocalPoint(this.props.media.get('id'));
   }
 
@@ -68,6 +70,10 @@ class Upload extends ImmutablePureComponent {
     this.setState({ focused: true });
   }
 
+  handleClick = () => {
+    this.setState({ focused: true });
+  }
+
   handleInputBlur = () => {
     const { dirtyDescription } = this.state;
 
@@ -88,7 +94,7 @@ class Upload extends ImmutablePureComponent {
     const y = ((focusY / -2) + .5) * 100;
 
     return (
-      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+      <div className='compose-form__upload' tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
           {({ scale }) => (
             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
index 7277b7f0f..ffcd6d281 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
@@ -56,6 +56,7 @@ export default class Conversation extends ImmutablePureComponent {
         otherAccounts={accounts}
         onMoveUp={this.handleHotkeyMoveUp}
         onMoveDown={this.handleHotkeyMoveDown}
+        onClick={this.handleClick}
       />
     );
   }
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
index 4684548e0..635c03c1d 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
@@ -9,14 +9,14 @@ import { debounce } from 'lodash';
 export default class ConversationsList extends ImmutablePureComponent {
 
   static propTypes = {
-    conversationIds: ImmutablePropTypes.list.isRequired,
+    conversations: ImmutablePropTypes.list.isRequired,
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
     onLoadMore: PropTypes.func,
     shouldUpdateScroll: PropTypes.func,
   };
 
-  getCurrentIndex = id => this.props.conversationIds.indexOf(id)
+  getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)
 
   handleMoveUp = id => {
     const elementIndex = this.getCurrentIndex(id) - 1;
@@ -41,22 +41,22 @@ export default class ConversationsList extends ImmutablePureComponent {
   }
 
   handleLoadOlder = debounce(() => {
-    const last = this.props.conversationIds.last();
+    const last = this.props.conversations.last();
 
-    if (last) {
-      this.props.onLoadMore(last);
+    if (last && last.get('last_status')) {
+      this.props.onLoadMore(last.get('last_status'));
     }
   }, 300, { leading: true })
 
   render () {
-    const { conversationIds, onLoadMore, ...other } = this.props;
+    const { conversations, onLoadMore, ...other } = this.props;
 
     return (
       <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
-        {conversationIds.map(item => (
+        {conversations.map(item => (
           <ConversationContainer
-            key={item}
-            conversationId={item}
+            key={item.get('id')}
+            conversationId={item.get('id')}
             onMoveUp={this.handleMoveUp}
             onMoveDown={this.handleMoveDown}
           />
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js
index 81ea812ad..57e17d96f 100644
--- a/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js
+++ b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js
@@ -3,7 +3,7 @@ import ConversationsList from '../components/conversations_list';
 import { expandConversations } from '../../../actions/conversations';
 
 const mapStateToProps = state => ({
-  conversationIds: state.getIn(['conversations', 'items']).map(x => x.get('id')),
+  conversations: state.getIn(['conversations', 'items']),
   isLoading: state.getIn(['conversations', 'isLoading'], true),
   hasMore: state.getIn(['conversations', 'hasMore'], false),
 });
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
index 36351ec02..164fdcc0b 100644
--- a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
@@ -2,7 +2,7 @@
 // https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
 
 import data from './emoji_mart_data_light';
-import { getData, getSanitizedData, intersect } from './emoji_utils';
+import { getData, getSanitizedData, uniq, intersect } from './emoji_utils';
 
 let originalPool = {};
 let index = {};
@@ -103,7 +103,7 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo
       }
     }
 
-    allResults = values.map((value) => {
+    const searchValue = (value) => {
       let aPool = pool,
         aIndex = index,
         length = 0;
@@ -150,15 +150,23 @@ function search(value, { emojisToShowFilter, maxResults, include, exclude, custo
       }
 
       return aIndex.results;
-    }).filter(a => a);
+    };
 
-    if (allResults.length > 1) {
-      results = intersect.apply(null, allResults);
-    } else if (allResults.length) {
-      results = allResults[0];
+    if (values.length > 1) {
+      results = searchValue(value);
     } else {
       results = [];
     }
+
+    allResults = values.map(searchValue).filter(a => a);
+
+    if (allResults.length > 1) {
+      allResults = intersect.apply(null, allResults);
+    } else if (allResults.length) {
+      allResults = allResults[0];
+    }
+
+    results = uniq(results.concat(allResults));
   }
 
   if (results) {
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index b52f3c4fa..9a87f7a3f 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -59,10 +59,12 @@ export default class Card extends React.PureComponent {
     card: ImmutablePropTypes.map,
     maxDescription: PropTypes.number,
     onOpenMedia: PropTypes.func.isRequired,
+    compact: PropTypes.boolean,
   };
 
   static defaultProps = {
     maxDescription: 50,
+    compact: false,
   };
 
   state = {
@@ -131,25 +133,25 @@ export default class Card extends React.PureComponent {
   }
 
   render () {
-    const { card, maxDescription } = this.props;
-    const { width, embedded }      = this.state;
+    const { card, maxDescription, compact } = this.props;
+    const { width, embedded } = this.state;
 
     if (card === null) {
       return null;
     }
 
     const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
-    const horizontal  = card.get('width') > card.get('height') && (card.get('width') + 100 >= width) || card.get('type') !== 'link';
-    const className   = classnames('status-card', { horizontal });
+    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
     const interactive = card.get('type') !== 'link';
+    const className   = classnames('status-card', { horizontal, compact, interactive });
     const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
-    const ratio       = card.get('width') / card.get('height');
+    const ratio       = compact ? 16 / 9 : card.get('width') / card.get('height');
     const height      = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
 
     const description = (
       <div className='status-card__content'>
         {title}
-        {!horizontal && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
+        {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
       </div>
     );
@@ -174,7 +176,7 @@ export default class Card extends React.PureComponent {
             <div className='status-card__actions'>
               <div>
                 <button onClick={this.handleEmbedClick}><i className={`fa fa-${iconVariant}`} /></button>
-                <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>
+                {horizontal && <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>}
               </div>
             </div>
           </div>
@@ -184,7 +186,7 @@ export default class Card extends React.PureComponent {
       return (
         <div className={className} ref={this.setRef}>
           {embed}
-          {description}
+          {!compact && description}
         </div>
       );
     } else if (card.get('image')) {
diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js
index a97404de1..6170d9fd8 100644
--- a/app/javascript/mastodon/features/status/containers/card_container.js
+++ b/app/javascript/mastodon/features/status/containers/card_container.js
@@ -2,7 +2,7 @@ import { connect } from 'react-redux';
 import Card from '../components/card';
 
 const mapStateToProps = (state, { statusId }) => ({
-  card: state.getIn(['cards', statusId], null),
+  card: state.getIn(['statuses', statusId, 'card'], null),
 });
 
 export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index e98566e26..2c98af1db 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -14,7 +14,6 @@ import relationships from './relationships';
 import settings from './settings';
 import push_notifications from './push_notifications';
 import status_lists from './status_lists';
-import cards from './cards';
 import mutes from './mutes';
 import reports from './reports';
 import contexts from './contexts';
@@ -46,7 +45,6 @@ const reducers = {
   relationships,
   settings,
   push_notifications,
-  cards,
   mutes,
   reports,
   contexts,
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 6e3d830da..2c58969f3 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -10,6 +10,7 @@ import {
   STATUS_REVEAL,
   STATUS_HIDE,
 } from '../actions/statuses';
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
 import { Map as ImmutableMap, fromJS } from 'immutable';
@@ -65,6 +66,8 @@ export default function statuses(state = initialState, action) {
     });
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.setIn([action.id, 'card'], fromJS(action.card));
   default:
     return state;
   }
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index a62974ec0..9cf783c84 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -5,7 +5,6 @@ import { start } from '../mastodon/common';
 start();
 
 function main() {
-  const { length } = require('stringz');
   const IntlMessageFormat = require('intl-messageformat').default;
   const { timeAgoString } = require('../mastodon/components/relative_timestamp');
   const { delegate } = require('rails-ujs');
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index f8eb37c58..f778ba06b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1669,6 +1669,7 @@ a.account__display-name {
   padding: 4px 0;
   border-radius: 4px;
   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  z-index: 9999;
 
   ul {
     list-style: none;
@@ -2560,6 +2561,9 @@ a.status-card {
   display: block;
   margin-top: 5px;
   font-size: 13px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .status-card__image {
@@ -2584,6 +2588,31 @@ a.status-card {
   }
 }
 
+.status-card.compact {
+  border-color: lighten($ui-base-color, 4%);
+
+  &.interactive {
+    border: 0;
+  }
+
+  .status-card__content {
+    padding: 8px;
+    padding-top: 10px;
+  }
+
+  .status-card__title {
+    white-space: nowrap;
+  }
+
+  .status-card__image {
+    flex: 0 0 60px;
+  }
+}
+
+a.status-card.compact:hover {
+  background-color: lighten($ui-base-color, 4%);
+}
+
 .status-card__image-image {
   border-radius: 4px 0 0 4px;
   display: block;
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
index a86e3e632..176fb5ce0 100644
--- a/app/javascript/styles/mastodon/rtl.scss
+++ b/app/javascript/styles/mastodon/rtl.scss
@@ -130,17 +130,6 @@ body.rtl {
     float: left;
   }
 
-  .activity-stream .detailed-status.light .detailed-status__display-name > div {
-    float: right;
-    margin-right: 0;
-    margin-left: 10px;
-  }
-
-  .activity-stream .detailed-status.light .detailed-status__meta span > span {
-    margin-left: 0;
-    margin-right: 6px;
-  }
-
   .status__action-bar {
 
     &__counter {
@@ -174,6 +163,10 @@ body.rtl {
     margin-right: 0;
   }
 
+  .detailed-status__display-name .display-name {
+    text-align: right;
+  }
+
   .detailed-status__display-avatar {
     margin-right: 0;
     margin-left: 10px;
@@ -359,4 +352,22 @@ body.rtl {
     margin-right: 15px;
     text-align: right;
   }
+
+  .fa-chevron-left::before {
+    content: "\F054";
+  }
+
+  .fa-chevron-right::before {
+    content: "\F053";
+  }
+
+  .column-back-button__icon {
+    margin-right: 0;
+    margin-left: 5px;
+  }
+
+  .column-header__setting-arrows .column-header__setting-btn:last-child {
+    padding-left: 0;
+    padding-right: 10px;
+  }
 }
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 7e6702a63..f1b38b18a 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -81,11 +81,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       @mentions << Mention.new(account: account, silent: true)
 
       # If there is at least one silent mention, then the status can be considered
-      # as a limited-audience status, and not strictly a direct message
+      # as a limited-audience status, and not strictly a direct message, but only
+      # if we considered a direct message in the first place
       next unless @params[:visibility] == :direct
 
       @params[:visibility] = :limited
     end
+
+    # If the payload was delivered to a specific inbox, the inbox owner must have
+    # access to it, unless they already have access to it anyway
+    return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] }
+
+    @mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true)
+
+    return unless @params[:visibility] == :direct
+
+    @params[:visibility] = :limited
   end
 
   def attach_tags(status)
@@ -118,7 +129,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     return if tag['name'].blank?
 
     hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
-    hashtag = Tag.where(name: hashtag).first_or_create(name: hashtag)
+    hashtag = Tag.where(name: hashtag).first_or_create!(name: hashtag)
 
     return if @tags.include?(hashtag)
 
diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb
index b7447d805..cc6b39279 100644
--- a/app/models/account_conversation.rb
+++ b/app/models/account_conversation.rb
@@ -58,6 +58,9 @@ class AccountConversation < ApplicationRecord
 
     def add_status(recipient, status)
       conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
+
+      return conversation if conversation.status_ids.include?(status.id)
+
       conversation.status_ids << status.id
       conversation.unread = status.account_id != recipient.id
       conversation.save
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 3e3cbdaed..0f787ebc4 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -148,6 +148,7 @@ class MediaAttachment < ApplicationRecord
     "#{x},#{y}"
   end
 
+  after_commit :reset_parent_cache, on: :update
   before_create :prepare_description, unless: :local?
   before_create :set_shortcode
   before_post_process :set_type_and_extension
@@ -252,4 +253,9 @@ class MediaAttachment < ApplicationRecord
       bitrate: movie.bitrate,
     }
   end
+
+  def reset_parent_cache
+    return if status_id.nil?
+    Rails.cache.delete("statuses/#{status_id}")
+  end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 438863589..f67a05b3c 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -94,6 +94,7 @@ class Status < ApplicationRecord
                    :conversation,
                    :status_stat,
                    :tags,
+                   :preview_cards,
                    :stream_entry,
                    active_mentions: :account,
                    reblog: [
@@ -101,6 +102,7 @@ class Status < ApplicationRecord
                      :application,
                      :stream_entry,
                      :tags,
+                     :preview_cards,
                      :media_attachments,
                      :conversation,
                      :status_stat,
@@ -168,6 +170,10 @@ class Status < ApplicationRecord
     reblog
   end
 
+  def preview_card
+    preview_cards.first
+  end
+
   def title
     if destroyed?
       "#{account.acct} deleted status"
@@ -241,10 +247,6 @@ class Status < ApplicationRecord
   before_validation :set_local
 
   class << self
-    def cache_ids
-      left_outer_joins(:status_stat).select('statuses.id, greatest(statuses.updated_at, status_stats.updated_at) AS updated_at')
-    end
-
     def selectable_visibilities
       visibilities.keys - %w(direct limited)
     end
diff --git a/app/models/status_stat.rb b/app/models/status_stat.rb
index 9d358776b..024c467e7 100644
--- a/app/models/status_stat.rb
+++ b/app/models/status_stat.rb
@@ -14,4 +14,12 @@
 
 class StatusStat < ApplicationRecord
   belongs_to :status, inverse_of: :status_stat
+
+  after_commit :reset_parent_cache
+
+  private
+
+  def reset_parent_cache
+    Rails.cache.delete("statuses/#{status_id}")
+  end
 end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 07839c5ae..8a61c1056 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -21,6 +21,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
   has_many :tags
   has_many :emojis, serializer: REST::CustomEmojiSerializer
 
+  has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
+
   def id
     object.id.to_s
   end
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index 1ec9ee5dd..8430d12d5 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -5,9 +5,10 @@ class ActivityPub::FetchRemoteAccountService < BaseService
 
   SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
 
-  # Should be called when uri has already been checked for locality
   # Does a WebFinger roundtrip on each call
   def call(uri, id: true, prefetched_body: nil, break_on_redirect: false)
+    return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri)
+
     @json = if prefetched_body.nil?
               fetch_resource(uri, id)
             else
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 4169c685b..462e5ee13 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -17,7 +17,8 @@ class FetchLinkCardService < BaseService
 
     return if @url.nil? || @status.preview_cards.any?
 
-    @url = @url.to_s
+    @mentions = status.mentions
+    @url      = @url.to_s
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
@@ -62,6 +63,7 @@ class FetchLinkCardService < BaseService
 
   def attach_card
     @status.preview_cards << @card
+    Rails.cache.delete(@status)
   end
 
   def parse_urls
@@ -81,9 +83,16 @@ class FetchLinkCardService < BaseService
     uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
   end
 
+  def mention_link?(a)
+    return false if @mentions.nil?
+    @mentions.any? do |mention|
+      a['href'] == TagManager.instance.url_for(mention.target)
+    end
+  end
+
   def skip_link?(a)
     # Avoid links for hashtags and mentions (microformats)
-    a['rel']&.include?('tag') || a['class']&.include?('u-url')
+    a['rel']&.include?('tag') || a['class']&.include?('u-url') || mention_link?(a)
   end
 
   def attempt_oembed
diff --git a/app/services/verify_link_service.rb b/app/services/verify_link_service.rb
index 3453b54c5..9f56249c7 100644
--- a/app/services/verify_link_service.rb
+++ b/app/services/verify_link_service.rb
@@ -25,7 +25,7 @@ class VerifyLinkService < BaseService
   end
 
   def link_back_present?
-    return false if @body.empty?
+    return false if @body.blank?
 
     links = Nokogiri::HTML(@body).xpath('//a[contains(concat(" ", normalize-space(@rel), " "), " me ")]|//link[contains(concat(" ", normalize-space(@rel), " "), " me ")]')
 
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index 66382db50..0e52702dc 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -14,4 +14,4 @@
 
         = yield
 
-= render template: 'layouts/application', locals: { body_classes: 'admin' }
+= render template: 'layouts/application'
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 6b61fa9c9..516851b04 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -6,8 +6,8 @@
 
   .fields-row
     .fields-row__column.fields-group.fields-row__column-6
-      = f.input :display_name, wrapper: :with_label, hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe
-      = f.input :note, wrapper: :with_label, hint: t('simple_form.hints.defaults.note', count: 500 - @account.note.size).html_safe
+      = f.input :display_name, wrapper: :with_label, input_html: { maxlength: 30 }, hint: false
+      = f.input :note, wrapper: :with_label, input_html: { maxlength: 500 }, hint: false
 
   .fields-row
     .fields-row__column.fields-row__column-6
@@ -36,8 +36,8 @@
 
         = f.simple_fields_for :fields do |fields_f|
           .row
-            = fields_f.input :name, placeholder: t('simple_form.labels.account.fields.name')
-            = fields_f.input :value, placeholder: t('simple_form.labels.account.fields.value')
+            = fields_f.input :name, placeholder: t('simple_form.labels.account.fields.name'), input_html: { maxlength: 255 }
+            = fields_f.input :value, placeholder: t('simple_form.labels.account.fields.value'), input_html: { maxlength: 255 }
 
     .fields-row__column.fields-group.fields-row__column-6
       %h6= t('verification.verification')
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index cd0531ba2..e996d5052 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -15,6 +15,7 @@
         %span.display-name
           %bdi
             %strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: autoplay)
+          &nbsp;
           %span.display-name__account
             = acct(status.account)
             = fa_icon('lock') if status.account.locked?
diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb
index 0e2e0eddd..a8a3ebf0f 100644
--- a/app/workers/activitypub/processing_worker.rb
+++ b/app/workers/activitypub/processing_worker.rb
@@ -5,7 +5,7 @@ class ActivityPub::ProcessingWorker
 
   sidekiq_options backtrace: true
 
-  def perform(account_id, body)
-    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true)
+  def perform(account_id, body, delivered_to_account_id = nil)
+    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id)
   end
 end