about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/components
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-11-10 08:50:11 -0600
committerStarfall <us@starfall.systems>2022-11-10 08:50:11 -0600
commit67d1a0476d77e2ed0ca15dd2981c54c2b90b0742 (patch)
tree152f8c13a341d76738e8e2c09b24711936e6af68 /app/javascript/flavours/glitch/components
parentb581e6b6d4a5ba9ed4ae17427b7f2d5d158be4e5 (diff)
parentee7e49d1b1323618e16026bc8db8ab7f9459cc2d (diff)
Merge remote-tracking branch 'glitch/main'
- Remove Helm charts
- Lots of conflicts with our removal of recommended settings and custom
  icons
Diffstat (limited to 'app/javascript/flavours/glitch/components')
-rw-r--r--app/javascript/flavours/glitch/components/account.js24
-rw-r--r--app/javascript/flavours/glitch/components/admin/Counter.js2
-rw-r--r--app/javascript/flavours/glitch/components/admin/Dimension.js4
-rw-r--r--app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js2
-rw-r--r--app/javascript/flavours/glitch/components/admin/Retention.js4
-rw-r--r--app/javascript/flavours/glitch/components/admin/Trends.js2
-rw-r--r--app/javascript/flavours/glitch/components/animated_number.js8
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js4
-rw-r--r--app/javascript/flavours/glitch/components/avatar.js4
-rw-r--r--app/javascript/flavours/glitch/components/avatar_composite.js2
-rw-r--r--app/javascript/flavours/glitch/components/avatar_overlay.js2
-rw-r--r--app/javascript/flavours/glitch/components/column.js2
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js6
-rw-r--r--app/javascript/flavours/glitch/components/dismissable_banner.js51
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js19
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js2
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.js9
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.js52
-rw-r--r--app/javascript/flavours/glitch/components/icon_button.js15
-rw-r--r--app/javascript/flavours/glitch/components/image.js33
-rw-r--r--app/javascript/flavours/glitch/components/intersection_observer_article.js6
-rw-r--r--app/javascript/flavours/glitch/components/link.js2
-rw-r--r--app/javascript/flavours/glitch/components/logo.js5
-rw-r--r--app/javascript/flavours/glitch/components/media_attachments.js2
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js4
-rw-r--r--app/javascript/flavours/glitch/components/missing_indicator.js5
-rw-r--r--app/javascript/flavours/glitch/components/navigation_portal.js35
-rw-r--r--app/javascript/flavours/glitch/components/not_signed_in_indicator.js12
-rw-r--r--app/javascript/flavours/glitch/components/poll.js10
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js4
-rw-r--r--app/javascript/flavours/glitch/components/server_banner.js93
-rw-r--r--app/javascript/flavours/glitch/components/short_number.js4
-rw-r--r--app/javascript/flavours/glitch/components/skeleton.js4
-rw-r--r--app/javascript/flavours/glitch/components/status.js50
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js98
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js12
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js2
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js2
39 files changed, 435 insertions, 168 deletions
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index 489f60736..8e810ce5f 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -7,8 +7,9 @@ import Permalink from './permalink';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/initial_state';
 import RelativeTimestamp from './relative_timestamp';
+import Skeleton from 'flavours/glitch/components/skeleton';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -26,7 +27,8 @@ export default @injectIntl
 class Account extends ImmutablePureComponent {
 
   static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
+    size: PropTypes.number,
+    account: ImmutablePropTypes.map,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -40,6 +42,10 @@ class Account extends ImmutablePureComponent {
     onActionClick: PropTypes.func,
   };
 
+  static defaultProps = {
+    size: 36,
+  };
+
   handleFollow = () => {
     this.props.onFollow(this.props.account);
   }
@@ -74,10 +80,20 @@ class Account extends ImmutablePureComponent {
       actionIcon,
       actionTitle,
       defaultAction,
+      size,
     } = this.props;
 
     if (!account) {
-      return <div />;
+      return (
+        <div className='account'>
+          <div className='account__wrapper'>
+            <div className='account__display-name'>
+              <div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
+              <DisplayName />
+            </div>
+          </div>
+        </div>
+      );
     }
 
     if (hidden) {
@@ -153,7 +169,7 @@ class Account extends ImmutablePureComponent {
       <div className='account'>
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
-            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={size} /></div>
             {mute_expires_at}
             <DisplayName account={account} />
           </Permalink>
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.js b/app/javascript/flavours/glitch/components/admin/Counter.js
index a4d6cef41..5b6a19f8d 100644
--- a/app/javascript/flavours/glitch/components/admin/Counter.js
+++ b/app/javascript/flavours/glitch/components/admin/Counter.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import api from 'flavours/glitch/util/api';
+import api from 'flavours/glitch/api';
 import { FormattedNumber } from 'react-intl';
 import { Sparklines, SparklinesCurve } from 'react-sparklines';
 import classNames from 'classnames';
diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.js b/app/javascript/flavours/glitch/components/admin/Dimension.js
index a924d093c..3dac8c6c2 100644
--- a/app/javascript/flavours/glitch/components/admin/Dimension.js
+++ b/app/javascript/flavours/glitch/components/admin/Dimension.js
@@ -1,8 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import api from 'flavours/glitch/util/api';
+import api from 'flavours/glitch/api';
 import { FormattedNumber } from 'react-intl';
-import { roundTo10 } from 'flavours/glitch/util/numbers';
+import { roundTo10 } from 'flavours/glitch/utils/numbers';
 import Skeleton from 'flavours/glitch/components/skeleton';
 
 export default class Dimension extends React.PureComponent {
diff --git a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
index 0f2a4fe36..771dbb452 100644
--- a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
+++ b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import api from 'flavours/glitch/util/api';
+import api from 'flavours/glitch/api';
 import { injectIntl, defineMessages } from 'react-intl';
 import classNames from 'classnames';
 
diff --git a/app/javascript/flavours/glitch/components/admin/Retention.js b/app/javascript/flavours/glitch/components/admin/Retention.js
index 6d7e4b279..9cc39040b 100644
--- a/app/javascript/flavours/glitch/components/admin/Retention.js
+++ b/app/javascript/flavours/glitch/components/admin/Retention.js
@@ -1,9 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import api from 'flavours/glitch/util/api';
+import api from 'flavours/glitch/api';
 import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
 import classNames from 'classnames';
-import { roundTo10 } from 'flavours/glitch/util/numbers';
+import { roundTo10 } from 'flavours/glitch/utils/numbers';
 
 const dateForCohort = cohort => {
   switch(cohort.frequency) {
diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js
index 60e367f00..4c17b69a0 100644
--- a/app/javascript/flavours/glitch/components/admin/Trends.js
+++ b/app/javascript/flavours/glitch/components/admin/Trends.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import api from 'flavours/glitch/util/api';
+import api from 'flavours/glitch/api';
 import { FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
 import Hashtag from 'flavours/glitch/components/hashtag';
diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js
index 3cc5173dd..9431c96f7 100644
--- a/app/javascript/flavours/glitch/components/animated_number.js
+++ b/app/javascript/flavours/glitch/components/animated_number.js
@@ -1,9 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { FormattedNumber } from 'react-intl';
+import ShortNumber from 'mastodon/components/short_number';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
-import { reduceMotion } from 'flavours/glitch/util/initial_state';
+import { reduceMotion } from 'flavours/glitch/initial_state';
 
 const obfuscatedCount = count => {
   if (count < 0) {
@@ -51,7 +51,7 @@ export default class AnimatedNumber extends React.PureComponent {
     const { direction } = this.state;
 
     if (reduceMotion) {
-      return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
+      return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
     }
 
     const styles = [{
@@ -65,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
         {items => (
           <span className='animated-number'>
             {items.map(({ key, data, style }) => (
-              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
+              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
             ))}
           </span>
         )}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
index d04c1eb68..83fafbd10 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
@@ -1,8 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
+import unicodeMapping from 'flavours/glitch/features/emoji/emoji_unicode_mapping_light';
 
-import { assetHost } from 'flavours/glitch/util/config';
+import { assetHost } from 'flavours/glitch/utils/config';
 
 export default class AutosuggestEmoji extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/components/avatar.js b/app/javascript/flavours/glitch/components/avatar.js
index 6d53a5298..38fd99af5 100644
--- a/app/javascript/flavours/glitch/components/avatar.js
+++ b/app/javascript/flavours/glitch/components/avatar.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
 import classNames from 'classnames';
 
 export default class Avatar extends React.PureComponent {
@@ -70,6 +70,8 @@ export default class Avatar extends React.PureComponent {
         onMouseLeave={this.handleMouseLeave}
         style={style}
         data-avatar-of={account && `@${account.get('acct')}`}
+        role='img'
+        aria-label={account?.get('acct')}
       />
     );
   }
diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js
index e30dfe68a..c0ce7761d 100644
--- a/app/javascript/flavours/glitch/components/avatar_composite.js
+++ b/app/javascript/flavours/glitch/components/avatar_composite.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
 
 export default class AvatarComposite extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/components/avatar_overlay.js b/app/javascript/flavours/glitch/components/avatar_overlay.js
index 23db5182b..01dec587a 100644
--- a/app/javascript/flavours/glitch/components/avatar_overlay.js
+++ b/app/javascript/flavours/glitch/components/avatar_overlay.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
 
 export default class AvatarOverlay extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/components/column.js b/app/javascript/flavours/glitch/components/column.js
index c9da7d329..cf0e6d5e4 100644
--- a/app/javascript/flavours/glitch/components/column.js
+++ b/app/javascript/flavours/glitch/components/column.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { supportsPassiveEvents } from 'detect-passive-events';
-import { scrollTop } from 'flavours/glitch/util/scroll';
+import { scrollTop } from '../scroll';
 
 export default class Column extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index 500612093..0f89b3a97 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -17,6 +17,7 @@ class ColumnHeader extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
+    identity: PropTypes.object,
   };
 
   static propTypes = {
@@ -62,7 +63,7 @@ class ColumnHeader extends React.PureComponent {
   }
 
   handleTitleClick = () => {
-    this.props.onClick();
+    this.props.onClick?.();
   }
 
   handleMoveLeft = () => {
@@ -150,13 +151,12 @@ class ColumnHeader extends React.PureComponent {
       collapsedContent.push(moveButtons);
     }
 
-    if (children || (multiColumn && this.props.onPin)) {
+    if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
       collapseButton = (
         <button
           className={collapsibleButtonClassName}
           title={formatMessage(collapsed ? messages.show : messages.hide)}
           aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
-          aria-pressed={collapsed ? 'false' : 'true'}
           onClick={this.handleToggleClick}
         >
           <i className='icon-with-badge'>
diff --git a/app/javascript/flavours/glitch/components/dismissable_banner.js b/app/javascript/flavours/glitch/components/dismissable_banner.js
new file mode 100644
index 000000000..ff52a619d
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/dismissable_banner.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import IconButton from './icon_button';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import { bannerSettings } from 'flavours/glitch/settings';
+
+const messages = defineMessages({
+  dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
+});
+
+export default @injectIntl
+class DismissableBanner extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    children: PropTypes.node,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    visible: !bannerSettings.get(this.props.id),
+  };
+
+  handleDismiss = () => {
+    const { id } = this.props;
+    this.setState({ visible: false }, () => bannerSettings.set(id, true));
+  }
+
+  render () {
+    const { visible } = this.state;
+
+    if (!visible) {
+      return null;
+    }
+
+    const { children, intl } = this.props;
+
+    return (
+      <div className='dismissable-banner'>
+        <div className='dismissable-banner__message'>
+          {children}
+        </div>
+
+        <div className='dismissable-banner__action'>
+          <IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index 9c7da744e..1c2297578 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -2,7 +2,8 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
-import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+import Skeleton from 'flavours/glitch/components/skeleton';
 
 export default class DisplayName extends React.PureComponent {
 
@@ -46,14 +47,15 @@ export default class DisplayName extends React.PureComponent {
 
     const computedClass = classNames('display-name', { inline }, className);
 
-    if (!account) return null;
-
     let displayName, suffix;
+    let acct;
 
-    let acct = account.get('acct');
+    if (account) {
+      acct = account.get('acct');
 
-    if (acct.indexOf('@') === -1 && localDomain) {
-      acct = `${acct}@${localDomain}`;
+      if (acct.indexOf('@') === -1 && localDomain) {
+        acct = `${acct}@${localDomain}`;
+      }
     }
 
     if (others && others.size > 0) {
@@ -80,9 +82,12 @@ export default class DisplayName extends React.PureComponent {
           <span className='display-name__account'>@{acct}</span>
         </a>
       );
-    } else {
+    } else if (account) {
       displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
       suffix      = <span className='display-name__account'>@{acct}</span>;
+    } else {
+      displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
+      suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
     }
 
     return (
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
index e04af8074..036e0b909 100644
--- a/app/javascript/flavours/glitch/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import IconButton from './icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { supportsPassiveEvents } from 'detect-passive-events';
 import classNames from 'classnames';
diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js
index 4537bde1d..e0ca3e2b0 100644
--- a/app/javascript/flavours/glitch/components/error_boundary.js
+++ b/app/javascript/flavours/glitch/components/error_boundary.js
@@ -1,9 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
-import { source_url } from 'flavours/glitch/util/initial_state';
-import { preferencesLink } from 'flavours/glitch/util/backend_links';
+import { source_url } from 'flavours/glitch/initial_state';
+import { preferencesLink } from 'flavours/glitch/utils/backend_links';
 import StackTrace from 'stacktrace-js';
+import { Helmet } from 'react-helmet';
 
 export default class ErrorBoundary extends React.PureComponent {
 
@@ -122,6 +123,10 @@ export default class ErrorBoundary extends React.PureComponent {
             )}
           </ul>
         </div>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js
index 5bbf32c87..422b9a8fa 100644
--- a/app/javascript/flavours/glitch/components/hashtag.js
+++ b/app/javascript/flavours/glitch/components/hashtag.js
@@ -1,7 +1,7 @@
 // @ts-check
 import React from 'react';
 import { Sparklines, SparklinesCurve } from 'react-sparklines';
-import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
@@ -9,10 +9,6 @@ import ShortNumber from 'flavours/glitch/components/short_number';
 import Skeleton from 'flavours/glitch/components/skeleton';
 import classNames from 'classnames';
 
-const messages = defineMessages({
-  totalVolume: { id: 'hashtag.total_volume', defaultMessage: 'Total volume in the last {days, plural, one {day} other {{days} days}}' },
-});
-
 class SilentErrorBoundary extends React.Component {
 
   static propTypes = {
@@ -42,7 +38,7 @@ class SilentErrorBoundary extends React.Component {
  *
  * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
  */
-const accountsCountRenderer = (displayNumber, pluralReady) => (
+export const accountsCountRenderer = (displayNumber, pluralReady) => (
   <FormattedMessage
     id='trends.counter_by_accounts'
     defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}'
@@ -60,7 +56,6 @@ export const ImmutableHashtag = ({ hashtag }) => (
     href={hashtag.get('url')}
     to={`/tags/${hashtag.get('name')}`}
     people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
-    uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
     history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
   />
 );
@@ -69,39 +64,52 @@ ImmutableHashtag.propTypes = {
   hashtag: ImmutablePropTypes.map.isRequired,
 };
 
-const Hashtag = injectIntl(({ name, href, to, people, uses, history, className, intl }) => (
+const Hashtag = ({ name, href, to, people, uses, history, className, description, withGraph }) => (
   <div className={classNames('trends__item', className)}>
     <div className='trends__item__name'>
       <Permalink href={href} to={to}>
         {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
       </Permalink>
 
-      {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
+      {description ? (
+        <span>{description}</span>
+      ) : (
+        typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />
+      )}
     </div>
 
-    <abbr className='trends__item__current' title={intl.formatMessage(messages.totalVolume, { days: 2 })}>
-      {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
-      <span className='trends__item__current__asterisk'>*</span>
-    </abbr>
-
-    <div className='trends__item__sparkline'>
-      <SilentErrorBoundary>
-        <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
-          <SparklinesCurve style={{ fill: 'none' }} />
-        </Sparklines>
-      </SilentErrorBoundary>
-    </div>
+    {typeof uses !== 'undefined' && (
+      <div className='trends__item__current'>
+        <ShortNumber value={uses} />
+      </div>
+    )}
+
+    {withGraph && (
+      <div className='trends__item__sparkline'>
+        <SilentErrorBoundary>
+          <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
+            <SparklinesCurve style={{ fill: 'none' }} />
+          </Sparklines>
+        </SilentErrorBoundary>
+      </div>
+    )}
   </div>
-));
+);
 
 Hashtag.propTypes = {
   name: PropTypes.string,
   href: PropTypes.string,
   to: PropTypes.string,
   people: PropTypes.number,
+  description: PropTypes.node,
   uses: PropTypes.number,
   history: PropTypes.arrayOf(PropTypes.number),
   className: PropTypes.string,
+  withGraph: PropTypes.bool,
+};
+
+Hashtag.defaultProps = {
+  withGraph: true,
 };
 
 export default Hashtag;
diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js
index 9ff745355..41a95e92f 100644
--- a/app/javascript/flavours/glitch/components/icon_button.js
+++ b/app/javascript/flavours/glitch/components/icon_button.js
@@ -1,5 +1,5 @@
 import React from 'react';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
@@ -18,7 +18,6 @@ export default class IconButton extends React.PureComponent {
     onKeyPress: PropTypes.func,
     size: PropTypes.number,
     active: PropTypes.bool,
-    pressed: PropTypes.bool,
     expanded: PropTypes.bool,
     style: PropTypes.object,
     activeStyle: PropTypes.object,
@@ -84,15 +83,21 @@ export default class IconButton extends React.PureComponent {
   }
 
   render () {
+    // Hack required for some icons which have an overriden size
+    let containerSize = '1.28571429em';
+    if (this.props.style?.fontSize) {
+      containerSize = `${this.props.size * 1.28571429}px`;
+    }
+
     let style = {
       fontSize: `${this.props.size}px`,
-      height: '1.28571429em',
+      height: containerSize,
       lineHeight: `${this.props.size}px`,
       ...this.props.style,
       ...(this.props.active ? this.props.activeStyle : {}),
     };
     if (!this.props.label) {
-      style.width = '1.28571429em';
+      style.width = containerSize;
     } else {
       style.textAlign = 'left';
     }
@@ -105,7 +110,6 @@ export default class IconButton extends React.PureComponent {
       icon,
       inverted,
       overlay,
-      pressed,
       tabIndex,
       title,
       counter,
@@ -150,7 +154,6 @@ export default class IconButton extends React.PureComponent {
     return (
       <button
         aria-label={title}
-        aria-pressed={pressed}
         aria-expanded={expanded}
         title={title}
         className={classes}
diff --git a/app/javascript/flavours/glitch/components/image.js b/app/javascript/flavours/glitch/components/image.js
new file mode 100644
index 000000000..6e81ddf08
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/image.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Blurhash from './blurhash';
+import classNames from 'classnames';
+
+export default class Image extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string,
+    srcSet: PropTypes.string,
+    blurhash: PropTypes.string,
+    className: PropTypes.string,
+  };
+
+  state = {
+    loaded: false,
+  };
+
+  handleLoad = () => this.setState({ loaded: true });
+
+  render () {
+    const { src, srcSet, blurhash, className } = this.props;
+    const { loaded } = this.state;
+
+    return (
+      <div className={classNames('image', { loaded }, className)} role='presentation'>
+        {blurhash && <Blurhash hash={blurhash} className='image__preview' />}
+        <img src={src} srcSet={srcSet} alt='' onLoad={this.handleLoad} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/intersection_observer_article.js b/app/javascript/flavours/glitch/components/intersection_observer_article.js
index 88f29892e..b28e44e4c 100644
--- a/app/javascript/flavours/glitch/components/intersection_observer_article.js
+++ b/app/javascript/flavours/glitch/components/intersection_observer_article.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
-import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
 
 // Diff these props in the "unrendered" state
 const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
@@ -94,7 +94,7 @@ export default class IntersectionObserverArticle extends React.Component {
     // When the browser gets a chance, test if we're still not intersecting,
     // and if so, set our isHidden to true to trigger an unrender. The point of
     // this is to save DOM nodes and avoid using up too much memory.
-    // See: https://github.com/tootsuite/mastodon/issues/2900
+    // See: https://github.com/mastodon/mastodon/issues/2900
     this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
   }
 
diff --git a/app/javascript/flavours/glitch/components/link.js b/app/javascript/flavours/glitch/components/link.js
index de99f7d42..bbec121a8 100644
--- a/app/javascript/flavours/glitch/components/link.js
+++ b/app/javascript/flavours/glitch/components/link.js
@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
 import React from 'react';
 
 //  Utils.
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
 
 //  Handlers.
 const handlers = {
diff --git a/app/javascript/flavours/glitch/components/logo.js b/app/javascript/flavours/glitch/components/logo.js
index d1c7f08a9..ee5c22496 100644
--- a/app/javascript/flavours/glitch/components/logo.js
+++ b/app/javascript/flavours/glitch/components/logo.js
@@ -1,8 +1,9 @@
 import React from 'react';
 
 const Logo = () => (
-  <svg viewBox='0 0 216.4144 232.00976' className='logo'>
-    <use xlinkHref='#mastodon-svg-logo' />
+  <svg viewBox='0 0 261 66' className='logo' role='img'>
+    <title>Mastodon</title>
+    <use xlinkHref='#logo-symbol-wordmark' />
   </svg>
 );
 
diff --git a/app/javascript/flavours/glitch/components/media_attachments.js b/app/javascript/flavours/glitch/components/media_attachments.js
index c8d133f09..a517fcf30 100644
--- a/app/javascript/flavours/glitch/components/media_attachments.js
+++ b/app/javascript/flavours/glitch/components/media_attachments.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components';
+import { MediaGallery, Video, Audio } from 'flavours/glitch/features/ui/util/async-components';
 import Bundle from 'flavours/glitch/features/ui/components/bundle';
 import noop from 'lodash/noop';
 
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 68195ea80..5414b4858 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -4,9 +4,9 @@ import PropTypes from 'prop-types';
 import { is } from 'immutable';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { isIOS } from 'flavours/glitch/util/is_mobile';
+import { isIOS } from '../is_mobile';
 import classNames from 'classnames';
-import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state';
+import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
 import { debounce } from 'lodash';
 import Blurhash from 'flavours/glitch/components/blurhash';
 
diff --git a/app/javascript/flavours/glitch/components/missing_indicator.js b/app/javascript/flavours/glitch/components/missing_indicator.js
index ee5bf7c1e..08e39c236 100644
--- a/app/javascript/flavours/glitch/components/missing_indicator.js
+++ b/app/javascript/flavours/glitch/components/missing_indicator.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
 import illustration from 'flavours/glitch/images/elephant_ui_disappointed.svg';
 import classNames from 'classnames';
+import { Helmet } from 'react-helmet';
 
 const MissingIndicator = ({ fullPage }) => (
   <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}>
@@ -14,6 +15,10 @@ const MissingIndicator = ({ fullPage }) => (
       <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
       <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
     </div>
+
+    <Helmet>
+      <meta name='robots' content='noindex' />
+    </Helmet>
   </div>
 );
 
diff --git a/app/javascript/flavours/glitch/components/navigation_portal.js b/app/javascript/flavours/glitch/components/navigation_portal.js
new file mode 100644
index 000000000..90afa1da0
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/navigation_portal.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import { Switch, Route, withRouter } from 'react-router-dom';
+import { showTrends } from 'flavours/glitch/initial_state';
+import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
+import AccountNavigation from 'flavours/glitch/features/account/navigation';
+
+const DefaultNavigation = () => (
+  <>
+    {showTrends && (
+      <>
+        <div className='flex-spacer' />
+        <Trends />
+      </>
+    )}
+  </>
+);
+
+export default @withRouter
+class NavigationPortal extends React.PureComponent {
+
+  render () {
+    return (
+      <Switch>
+        <Route path='/@:acct' exact component={AccountNavigation} />
+        <Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
+        <Route path='/@:acct/with_replies' exact component={AccountNavigation} />
+        <Route path='/@:acct/followers' exact component={AccountNavigation} />
+        <Route path='/@:acct/following' exact component={AccountNavigation} />
+        <Route path='/@:acct/media' exact component={AccountNavigation} />
+        <Route component={DefaultNavigation} />
+      </Switch>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/not_signed_in_indicator.js b/app/javascript/flavours/glitch/components/not_signed_in_indicator.js
new file mode 100644
index 000000000..b440c6be2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/not_signed_in_indicator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const NotSignedInIndicator = () => (
+  <div className='scrollable scrollable--flex'>
+    <div className='empty-column-indicator'>
+      <FormattedMessage id='not_signed_in_indicator.not_signed_in' defaultMessage='You need to sign in to access this resource.' />
+    </div>
+  </div>
+);
+
+export default NotSignedInIndicator;
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index 970b00705..da65cd241 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -4,10 +4,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from 'flavours/glitch/features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import escapeTextContentForBrowser from 'escape-html';
-import emojify from 'flavours/glitch/util/emoji';
+import emojify from 'flavours/glitch/features/emoji/emoji';
 import RelativeTimestamp from './relative_timestamp';
 import Icon from 'flavours/glitch/components/icon';
 
@@ -34,6 +34,10 @@ const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
 export default @injectIntl
 class Poll extends ImmutablePureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     poll: ImmutablePropTypes.map,
     intl: PropTypes.object.isRequired,
@@ -217,7 +221,7 @@ class Poll extends ImmutablePureComponent {
         </ul>
 
         <div className='poll__footer'>
-          {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
+          {!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
           {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
           {votesCount}
           {poll.get('expires_at') && <span> · {timeRemaining}</span>}
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index 50bfacc6a..8eb2b66d4 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -4,11 +4,11 @@ import PropTypes from 'prop-types';
 import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
 import LoadMore from './load_more';
 import LoadPending from './load_pending';
-import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper';
+import IntersectionObserverWrapper from 'flavours/glitch/features/ui/util/intersection_observer_wrapper';
 import { throttle } from 'lodash';
 import { List as ImmutableList } from 'immutable';
 import classNames from 'classnames';
-import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
 import LoadingIndicator from './loading_indicator';
 import { connect } from 'react-redux';
 
diff --git a/app/javascript/flavours/glitch/components/server_banner.js b/app/javascript/flavours/glitch/components/server_banner.js
new file mode 100644
index 000000000..36e0ff238
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/server_banner.js
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { fetchServer } from 'flavours/glitch/actions/server';
+import ShortNumber from 'flavours/glitch/components/short_number';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import Account from 'flavours/glitch/containers/account_container';
+import { domain } from 'flavours/glitch/initial_state';
+import Image from 'flavours/glitch/components/image';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+  aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
+});
+
+const mapStateToProps = state => ({
+  server: state.getIn(['server', 'server']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class ServerBanner extends React.PureComponent {
+
+  static propTypes = {
+    server: PropTypes.object,
+    dispatch: PropTypes.func,
+    intl: PropTypes.object,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchServer());
+  }
+
+  render () {
+    const { server, intl } = this.props;
+    const isLoading = server.get('isLoading');
+
+    return (
+      <div className='server-banner'>
+        <div className='server-banner__introduction'>
+          <FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
+        </div>
+
+        <Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
+
+        <div className='server-banner__description'>
+          {isLoading ? (
+            <>
+              <Skeleton width='100%' />
+              <br />
+              <Skeleton width='100%' />
+              <br />
+              <Skeleton width='70%' />
+            </>
+          ) : server.get('description')}
+        </div>
+
+        <div className='server-banner__meta'>
+          <div className='server-banner__meta__column'>
+            <h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
+
+            <Account id={server.getIn(['contact', 'account', 'id'])} size={36} />
+          </div>
+
+          <div className='server-banner__meta__column'>
+            <h4><FormattedMessage id='server_banner.server_stats' defaultMessage='Server stats:' /></h4>
+
+            {isLoading ? (
+              <>
+                <strong className='server-banner__number'><Skeleton width='10ch' /></strong>
+                <br />
+                <span className='server-banner__number-label'><Skeleton width='5ch' /></span>
+              </>
+            ) : (
+              <>
+                <strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong>
+                <br />
+                <span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span>
+              </>
+            )}
+          </div>
+        </div>
+
+        <hr className='spacer' />
+
+        <Link className='button button--block button-secondary' to='/about'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></Link>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/short_number.js b/app/javascript/flavours/glitch/components/short_number.js
index e4ba09634..535c17727 100644
--- a/app/javascript/flavours/glitch/components/short_number.js
+++ b/app/javascript/flavours/glitch/components/short_number.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../util/numbers';
+import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
 import { FormattedMessage, FormattedNumber } from 'react-intl';
 // @ts-check
 
@@ -56,7 +56,7 @@ ShortNumber.propTypes = {
 
 /**
  * @typedef {object} ShortNumberCounterProps
- * @property {import('../util/number').ShortNumber} value Short number
+ * @property {import('../utils/number').ShortNumber} value Short number
  */
 
 /**
diff --git a/app/javascript/flavours/glitch/components/skeleton.js b/app/javascript/flavours/glitch/components/skeleton.js
index 09093e99c..6a17ffb26 100644
--- a/app/javascript/flavours/glitch/components/skeleton.js
+++ b/app/javascript/flavours/glitch/components/skeleton.js
@@ -4,8 +4,8 @@ import PropTypes from 'prop-types';
 const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
 
 Skeleton.propTypes = {
-  width: PropTypes.number,
-  height: PropTypes.number,
+  width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+  height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
 };
 
 export default Skeleton;
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index e238456c5..366a98d82 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -10,13 +10,13 @@ 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, Audio } from 'flavours/glitch/util/async-components';
+import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
 import { HotKeys } from 'react-hotkeys';
 import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
 import classNames from 'classnames';
-import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
+import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
 import PollContainer from 'flavours/glitch/containers/poll_container';
-import { displayMedia } from 'flavours/glitch/util/initial_state';
+import { displayMedia } from 'flavours/glitch/initial_state';
 import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
 // We use the component (and not the container) since we do not want
@@ -83,6 +83,7 @@ class Status extends ImmutablePureComponent {
     onEmbed: PropTypes.func,
     onHeightChange: PropTypes.func,
     onToggleHidden: PropTypes.func,
+    onInteractionModal: PropTypes.func,
     muted: PropTypes.bool,
     hidden: PropTypes.bool,
     unread: PropTypes.bool,
@@ -99,8 +100,11 @@ class Status extends ImmutablePureComponent {
     onClick: PropTypes.func,
     scrollKey: PropTypes.string,
     deployPictureInPicture: PropTypes.func,
-    usingPiP: PropTypes.bool,
     settings: ImmutablePropTypes.map.isRequired,
+    pictureInPicture: PropTypes.shape({
+      inUse: PropTypes.bool,
+      available: PropTypes.bool,
+    }),
   };
 
   state = {
@@ -126,7 +130,7 @@ class Status extends ImmutablePureComponent {
     'hidden',
     'expanded',
     'unread',
-    'usingPiP',
+    'pictureInPicture',
   ]
 
   updateOnStates = [
@@ -495,7 +499,6 @@ class Status extends ImmutablePureComponent {
       settings,
       collapsed,
       muted,
-      prepend,
       intersectionObserverWrapper,
       onOpenVideo,
       onOpenMedia,
@@ -503,7 +506,7 @@ class Status extends ImmutablePureComponent {
       hidden,
       unread,
       featured,
-      usingPiP,
+      pictureInPicture,
       ...other
     } = this.props;
     const { isCollapsed, forceFilter } = this.state;
@@ -595,7 +598,7 @@ class Status extends ImmutablePureComponent {
 
     attachments = status.get('media_attachments');
 
-    if (usingPiP) {
+    if (pictureInPicture.inUse) {
       media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
       mediaIcons.push('video-camera');
     } else if (attachments.size > 0) {
@@ -623,7 +626,7 @@ class Status extends ImmutablePureComponent {
                 width={this.props.cachedMediaWidth}
                 height={110}
                 cacheWidth={this.props.cacheMediaWidth}
-                deployPictureInPicture={this.handleDeployPictureInPicture}
+                deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined}
                 sensitive={status.get('sensitive')}
                 blurhash={attachment.get('blurhash')}
                 visible={this.state.showMedia}
@@ -652,7 +655,7 @@ class Status extends ImmutablePureComponent {
               onOpenVideo={this.handleOpenVideo}
               width={this.props.cachedMediaWidth}
               cacheWidth={this.props.cacheMediaWidth}
-              deployPictureInPicture={this.handleDeployPictureInPicture}
+              deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined}
               visible={this.state.showMedia}
               onToggleVisibility={this.handleToggleMediaVisibility}
             />)}
@@ -709,20 +712,31 @@ class Status extends ImmutablePureComponent {
       'data-status-by': `@${status.getIn(['account', 'acct'])}`,
     };
 
-    if (prepend && account) {
+    let prepend;
+
+    if (this.props.prepend && account) {
       const notifKind = {
         favourite: 'favourited',
         reblog: 'boosted',
         reblogged_by: 'boosted',
         status: 'posted',
-      }[prepend];
+      }[this.props.prepend];
 
       selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
+
+      prepend = (
+        <StatusPrepend
+          type={this.props.prepend}
+          account={account}
+          parseClick={parseClick}
+          notificationId={this.props.notificationId}
+        />
+      );
     }
 
     let rebloggedByText;
 
-    if (prepend === 'reblog') {
+    if (this.props.prepend === 'reblog') {
       rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') });
     }
 
@@ -745,16 +759,10 @@ class Status extends ImmutablePureComponent {
           data-featured={featured ? 'true' : null}
           aria-label={textForScreenReader(intl, status, rebloggedByText, !status.get('hidden'))}
         >
+          {!muted && prepend}
           <header className='status__info'>
             <span>
-              {prepend && account ? (
-                <StatusPrepend
-                  type={prepend}
-                  account={account}
-                  parseClick={parseClick}
-                  notificationId={this.props.notificationId}
-                />
-              ) : null}
+              {muted && prepend}
               {!muted || !isCollapsed ? (
                 <StatusHeader
                   status={status}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index c0cd496ce..977c98ccb 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -5,9 +5,9 @@ import IconButton from './icon_button';
 import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/initial_state';
 import RelativeTimestamp from './relative_timestamp';
-import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links';
+import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
 import classNames from 'classnames';
 import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
 
@@ -42,6 +42,7 @@ const messages = defineMessages({
   hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
   edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
   filter: { id: 'status.filter', defaultMessage: 'Filter this post' },
+  openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
 });
 
 export default @injectIntl
@@ -69,7 +70,9 @@ class StatusActionBar extends ImmutablePureComponent {
     onBookmark: PropTypes.func,
     onFilter: PropTypes.func,
     onAddFilter: PropTypes.func,
+    onInteractionModal: PropTypes.func,
     withDismiss: PropTypes.bool,
+    withCounters: PropTypes.bool,
     showReplyCount: PropTypes.bool,
     scrollKey: PropTypes.string,
     intl: PropTypes.object.isRequired,
@@ -80,14 +83,17 @@ class StatusActionBar extends ImmutablePureComponent {
   updateOnProps = [
     'status',
     'showReplyCount',
+    'withCounters',
     'withDismiss',
   ]
 
   handleReplyClick = () => {
-    if (me) {
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
       this.props.onReply(this.props.status, this.context.router.history);
     } else {
-      this._openInteractionDialog('reply');
+      this.props.onInteractionModal('reply', this.props.status);
     }
   }
 
@@ -99,28 +105,28 @@ class StatusActionBar extends ImmutablePureComponent {
   }
 
   handleFavouriteClick = (e) => {
-    if (me) {
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
       this.props.onFavourite(this.props.status, e);
     } else {
-      this._openInteractionDialog('favourite');
+      this.props.onInteractionModal('favourite', this.props.status);
     }
   }
 
-  handleBookmarkClick = (e) => {
-    this.props.onBookmark(this.props.status, e);
-  }
-
   handleReblogClick = e => {
-    if (me) {
+    const { signedIn } = this.context.identity;
+
+    if (signedIn) {
       this.props.onReblog(this.props.status, e);
     } else {
-      this._openInteractionDialog('reblog');
+      this.props.onInteractionModal('reblog', this.props.status);
     }
   }
 
-  _openInteractionDialog = type => {
-    window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
-   }
+  handleBookmarkClick = (e) => {
+    this.props.onBookmark(this.props.status, e);
+  }
 
   handleDeleteClick = () => {
     this.props.onDelete(this.props.status, this.context.router.history);
@@ -177,22 +183,8 @@ class StatusActionBar extends ImmutablePureComponent {
   }
 
   handleCopy = () => {
-    const url      = this.props.status.get('url');
-    const textarea = document.createElement('textarea');
-
-    textarea.textContent    = url;
-    textarea.style.position = 'fixed';
-
-    document.body.appendChild(textarea);
-
-    try {
-      textarea.select();
-      document.execCommand('copy');
-    } catch (e) {
-
-    } finally {
-      document.body.removeChild(textarea);
-    }
+    const url = this.props.status.get('url');
+    navigator.clipboard.writeText(url);
   }
 
   handleHideClick = () => {
@@ -204,13 +196,14 @@ class StatusActionBar extends ImmutablePureComponent {
   }
 
   render () {
-    const { status, intl, withDismiss, showReplyCount, scrollKey } = this.props;
+    const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
 
     const anonymousAccess    = !me;
     const mutingConversation = status.get('muted');
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
     const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
     const writtenByMe        = status.getIn(['account', 'id']) === me;
+    const isRemote           = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
 
     let menu = [];
     let reblogIcon = 'retweet';
@@ -220,6 +213,9 @@ class StatusActionBar extends ImmutablePureComponent {
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
 
     if (publicStatus) {
+      if (isRemote) {
+        menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+      }
       menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
     }
@@ -237,7 +233,7 @@ class StatusActionBar extends ImmutablePureComponent {
     }
 
     if (writtenByMe) {
-      // menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
       menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
     } else {
@@ -283,27 +279,6 @@ class StatusActionBar extends ImmutablePureComponent {
       <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
     );
 
-    let replyButton = (
-      <IconButton
-        className='status__action-bar-button'
-        title={replyTitle}
-        icon={replyIcon}
-        onClick={this.handleReplyClick}
-      />
-    );
-    if (showReplyCount) {
-      replyButton = (
-        <IconButton
-          className='status__action-bar-button'
-          title={replyTitle}
-          icon={replyIcon}
-          onClick={this.handleReplyClick}
-          counter={status.get('replies_count')}
-          obfuscateCount
-        />
-      );
-    }
-
     const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
 
     let reblogTitle = '';
@@ -323,11 +298,18 @@ class StatusActionBar extends ImmutablePureComponent {
 
     return (
       <div className='status__action-bar'>
-        {replyButton}
-        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} />
-        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        <IconButton
+          className='status__action-bar-button'
+          title={replyTitle}
+          icon={replyIcon}
+          onClick={this.handleReplyClick}
+          counter={showReplyCount ? status.get('replies_count') : undefined}
+          obfuscateCount
+        />
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon={reblogIcon} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
         {shareButton}
-        <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
+        <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
 
         {filterButton}
 
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 39891da4f..c618cedca 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -5,8 +5,8 @@ import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
 import classnames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
-import { autoPlayGif } from 'flavours/glitch/util/initial_state';
-import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
+import { autoPlayGif } from 'flavours/glitch/initial_state';
+import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
 
 const textMatchesTarget = (text, origin, host) => {
   return (text === origin || text === host
@@ -124,6 +124,9 @@ export default class StatusContent extends React.PureComponent {
         link.setAttribute('title', link.href);
         link.classList.add('unhandled-link');
 
+      link.setAttribute('target', '_blank');
+      link.setAttribute('rel', 'noopener nofollow noreferrer');
+
         try {
           if (tagLinks && isLinkMisleading(link)) {
             // Add a tag besides the link to display its origin
@@ -149,9 +152,6 @@ export default class StatusContent extends React.PureComponent {
           if (tagLinks && e instanceof TypeError) link.removeAttribute('href');
         }
       }
-
-      link.setAttribute('target', '_blank');
-      link.setAttribute('rel', 'noopener noreferrer');
     }
   }
 
@@ -332,7 +332,7 @@ export default class StatusContent extends React.PureComponent {
           >
             <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={lang} />
             {' '}
-            <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
+            <button type='button' className='status__content__spoiler-link' onClick={this.handleSpoilerClick} aria-expanded={!hidden}>
               {toggleText}
             </button>
           </p>
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index 2226aaef2..71ffb2e56 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -8,7 +8,7 @@ import { defineMessages, injectIntl } from 'react-intl';
 import IconButton from './icon_button';
 import VisibilityIcon from './status_visibility_icon';
 import Icon from 'flavours/glitch/components/icon';
-import { languages } from 'flavours/glitch/util/initial_state';
+import { languages } from 'flavours/glitch/initial_state';
 
 //  Messages for use with internationalization stuff.
 const messages = defineMessages({
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index 9095e087e..0d843a27d 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -22,9 +22,11 @@ export default class StatusList extends ImmutablePureComponent {
     isPartial: PropTypes.bool,
     hasMore: PropTypes.bool,
     prepend: PropTypes.node,
-    alwaysPrepend: PropTypes.bool,
     emptyMessage: PropTypes.node,
+    alwaysPrepend: PropTypes.bool,
+    withCounters: PropTypes.bool,
     timelineId: PropTypes.string.isRequired,
+    regex: PropTypes.string,
   };
 
   static defaultProps = {
@@ -99,6 +101,7 @@ export default class StatusList extends ImmutablePureComponent {
           onMoveDown={this.handleMoveDown}
           contextType={timelineId}
           scrollKey={this.props.scrollKey}
+          withCounters={this.props.withCounters}
         />
       ))
     ) : null;
@@ -113,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent {
           onMoveDown={this.handleMoveDown}
           contextType={timelineId}
           scrollKey={this.props.scrollKey}
+          withCounters={this.props.withCounters}
         />
       )).concat(scrollableContent);
     }
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index d85009362..f82533062 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { FormattedMessage } from 'react-intl';
 import Icon from 'flavours/glitch/components/icon';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/initial_state';
 
 export default class StatusPrepend extends React.PureComponent {