about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/mastodon/common.js6
-rw-r--r--app/javascript/mastodon/components/display_name.js12
-rw-r--r--app/javascript/mastodon/components/status.js2
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js86
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js132
-rw-r--r--app/javascript/mastodon/features/status/containers/detailed_status_container.js172
-rw-r--r--app/javascript/mastodon/features/status/index.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js6
-rw-r--r--app/javascript/mastodon/locales/ca.json2
-rw-r--r--app/javascript/mastodon/locales/cy.json22
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json37
-rw-r--r--app/javascript/mastodon/locales/ro.json2
-rw-r--r--app/javascript/mastodon/locales/sk.json2
-rw-r--r--app/javascript/packs/public.js8
-rw-r--r--app/javascript/styles/mastodon/about.scss2
-rw-r--r--app/javascript/styles/mastodon/containers.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss2
-rw-r--r--app/javascript/styles/mastodon/widgets.scss90
19 files changed, 515 insertions, 77 deletions
diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js
index 2b10b8c30..fba21316a 100644
--- a/app/javascript/mastodon/common.js
+++ b/app/javascript/mastodon/common.js
@@ -4,5 +4,9 @@ export function start() {
   require('font-awesome/css/font-awesome.css');
   require.context('../images/', true);
 
-  Rails.start();
+  try {
+    Rails.start();
+  } catch (e) {
+    // If called twice
+  }
 };
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index c2c40cb3f..acddf77c5 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -1,15 +1,17 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
 
 export default class DisplayName extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
     others: ImmutablePropTypes.list,
+    localDomain: PropTypes.string,
   };
 
   render () {
-    const { account, others } = this.props;
+    const { account, others, localDomain } = this.props;
     const displayNameHtml = { __html: account.get('display_name_html') };
 
     let suffix;
@@ -17,7 +19,13 @@ export default class DisplayName extends React.PureComponent {
     if (others && others.size > 1) {
       suffix = `+${others.size}`;
     } else {
-      suffix = <span className='display-name__account'>@{account.get('acct')}</span>;
+      let acct = account.get('acct');
+
+      if (acct.indexOf('@') === -1 && localDomain) {
+        acct = `${acct}@${localDomain}`;
+      }
+
+      suffix = <span className='display-name__account'>@{acct}</span>;
     }
 
     return (
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index fd0780025..20d838500 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -77,7 +77,7 @@ class Status extends ImmutablePureComponent {
     'account',
     'muted',
     'hidden',
-  ]
+  ];
 
   handleClick = () => {
     if (this.props.onClick) {
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
index 759922638..333726f94 100644
--- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -1,28 +1,32 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import StatusListContainer from '../../ui/containers/status_list_container';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import { expandHashtagTimeline } from '../../../actions/timelines';
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
 import { connectHashtagStream } from '../../../actions/streaming';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList } from 'immutable';
+import DetailedStatusContainer from '../../status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../../components/loading_indicator';
 
-export default @connect()
+const mapStateToProps = (state, { hashtag }) => ({
+  statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
+  hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
+});
+
+export default @connect(mapStateToProps)
 class HashtagTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool.isRequired,
+    hasMore: PropTypes.bool.isRequired,
     hashtag: PropTypes.string.isRequired,
   };
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
-  }
-
-  setRef = c => {
-    this.column = c;
-  }
-
   componentDidMount () {
     const { dispatch, hashtag } = this.props;
 
@@ -37,28 +41,52 @@ class HashtagTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+  handleLoadMore = () => {
+    const maxId = this.props.statusIds.last();
+
+    if (maxId) {
+      this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+    }
+  }
+
+  setRef = c => {
+    this.masonry = c;
   }
 
+  handleHeightChange = debounce(() => {
+    if (!this.masonry) {
+      return;
+    }
+
+    this.masonry.forcePack();
+  }, 50)
+
   render () {
-    const { hashtag } = this.props;
+    const { statusIds, hasMore, isLoading } = this.props;
+
+    const sizes = [
+      { columns: 1, gutter: 0 },
+      { mq: '415px', columns: 1, gutter: 10 },
+      { mq: '640px', columns: 2, gutter: 10 },
+      { mq: '960px', columns: 3, gutter: 10 },
+      { mq: '1255px', columns: 3, gutter: 10 },
+    ];
+
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
 
     return (
-      <Column ref={this.setRef}>
-        <ColumnHeader
-          icon='hashtag'
-          title={hashtag}
-          onClick={this.handleHeaderClick}
-        />
-
-        <StatusListContainer
-          trackScroll={false}
-          scrollKey='standalone_hashtag_timeline'
-          timelineId={`hashtag:${hashtag}`}
-          onLoadMore={this.handleLoadMore}
-        />
-      </Column>
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
+        {statusIds.map(statusId => (
+          <div className='statuses-grid__item' key={statusId}>
+            <DetailedStatusContainer
+              id={statusId}
+              compact
+              measureHeight
+              onHeightChange={this.handleHeightChange}
+            />
+          </div>
+        )).toArray()}
+      </Masonry>
     );
   }
 
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b0dea8817..0630387d2 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -11,6 +11,8 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from '../../video';
+import scheduleIdleTask from '../../ui/util/schedule_idle_task';
+import classNames from 'classnames';
 
 export default class DetailedStatus extends ImmutablePureComponent {
 
@@ -23,10 +25,18 @@ export default class DetailedStatus extends ImmutablePureComponent {
     onOpenMedia: PropTypes.func.isRequired,
     onOpenVideo: PropTypes.func.isRequired,
     onToggleHidden: PropTypes.func.isRequired,
+    measureHeight: PropTypes.bool,
+    onHeightChange: PropTypes.func,
+    domain: PropTypes.string.isRequired,
+    compact: PropTypes.bool,
+  };
+
+  state = {
+    height: null,
   };
 
   handleAccountClick = (e) => {
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
       e.preventDefault();
       this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
     }
@@ -42,13 +52,57 @@ export default class DetailedStatus extends ImmutablePureComponent {
     this.props.onToggleHidden(this.props.status);
   }
 
+  _measureHeight (heightJustChanged) {
+    if (this.props.measureHeight && this.node) {
+      scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
+
+      if (this.props.onHeightChange && heightJustChanged) {
+        this.props.onHeightChange();
+      }
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+    this._measureHeight();
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    this._measureHeight(prevState.height !== this.state.height);
+  }
+
+  handleModalLink = e => {
+    e.preventDefault();
+
+    let href;
+
+    if (e.target.nodeName !== 'A') {
+      href = e.target.parentNode.href;
+    } else {
+      href = e.target.href;
+    }
+
+    window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+  }
+
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+    const outerStyle = { boxSizing: 'border-box' };
+    const { compact } = this.props;
+
+    if (!status) {
+      return null;
+    }
 
     let media           = '';
     let applicationLink = '';
     let reblogLink = '';
     let reblogIcon = 'retweet';
+    let favouriteLink = '';
+
+    if (this.props.measureHeight) {
+      outerStyle.height = `${this.state.height}px`;
+    }
 
     if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@@ -95,35 +149,63 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     if (status.get('visibility') === 'private') {
       reblogLink = <i className={`fa fa-${reblogIcon}`} />;
+    } else if (this.context.router) {
+      reblogLink = (
+        <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+          <i className={`fa fa-${reblogIcon}`} />
+          <span className='detailed-status__reblogs'>
+            <FormattedNumber value={status.get('reblogs_count')} />
+          </span>
+        </Link>
+      );
     } else {
-      reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
-        <i className={`fa fa-${reblogIcon}`} />
-        <span className='detailed-status__reblogs'>
-          <FormattedNumber value={status.get('reblogs_count')} />
-        </span>
-      </Link>);
+      reblogLink = (
+        <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <i className={`fa fa-${reblogIcon}`} />
+          <span className='detailed-status__reblogs'>
+            <FormattedNumber value={status.get('reblogs_count')} />
+          </span>
+        </a>
+      );
     }
 
-    return (
-      <div className='detailed-status'>
-        <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
-          <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
-          <DisplayName account={status.get('account')} />
+    if (this.context.router) {
+      favouriteLink = (
+        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+          <i className='fa fa-star' />
+          <span className='detailed-status__favorites'>
+            <FormattedNumber value={status.get('favourites_count')} />
+          </span>
+        </Link>
+      );
+    } else {
+      favouriteLink = (
+        <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <i className='fa fa-star' />
+          <span className='detailed-status__favorites'>
+            <FormattedNumber value={status.get('favourites_count')} />
+          </span>
         </a>
+      );
+    }
 
-        <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
-
-        {media}
-
-        <div className='detailed-status__meta'>
-          <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
-            <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-          </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
-            <i className='fa fa-star' />
-            <span className='detailed-status__favorites'>
-              <FormattedNumber value={status.get('favourites_count')} />
-            </span>
-          </Link>
+    return (
+      <div style={outerStyle}>
+        <div ref={this.setRef} className={classNames('detailed-status', { compact })}>
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+            <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
+            <DisplayName account={status.get('account')} localDomain={this.props.domain} />
+          </a>
+
+          <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
+
+          {media}
+
+          <div className='detailed-status__meta'>
+            <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
+              <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+            </a>{applicationLink} · {reblogLink} · {favouriteLink}
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
new file mode 100644
index 000000000..2c0db0a6b
--- /dev/null
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -0,0 +1,172 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import DetailedStatus from '../components/detailed_status';
+import { makeGetStatus } from '../../../selectors';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from '../../../actions/compose';
+import {
+  reblog,
+  favourite,
+  unreblog,
+  unfavourite,
+  pin,
+  unpin,
+} from '../../../actions/interactions';
+import { blockAccount } from '../../../actions/accounts';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  hideStatus,
+  revealStatus,
+} from '../../../actions/statuses';
+import { initMuteModal } from '../../../actions/mutes';
+import { initReport } from '../../../actions/reports';
+import { openModal } from '../../../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from '../../../initial_state';
+import { showAlertForError } from '../../../actions/alerts';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props),
+    domain: state.getIn(['meta', 'domain']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onReply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  onModalReblog (status) {
+    dispatch(reblog(status));
+  },
+
+  onReblog (status, e) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !boostModal) {
+        this.onModalReblog(status);
+      } else {
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+      }
+    }
+  },
+
+  onFavourite (status) {
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      dispatch(favourite(status));
+    }
+  },
+
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', {
+      url: status.get('url'),
+      onError: error => dispatch(showAlertForError(error)),
+    }));
+  },
+
+  onDelete (status, history, withRedraft = false) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onOpenMedia (media, index) {
+    dispatch(openModal('MEDIA', { media, index }));
+  },
+
+  onOpenVideo (media, time) {
+    dispatch(openModal('VIDEO', { media, time }));
+  },
+
+  onBlock (account) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockConfirm),
+      onConfirm: () => dispatch(blockAccount(account.get('id'))),
+    }));
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
+  },
+
+  onMute (account) {
+    dispatch(initMuteModal(account));
+  },
+
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+  onToggleHidden (status) {
+    if (status.get('hidden')) {
+      dispatch(revealStatus(status.get('id')));
+    } else {
+      dispatch(hideStatus(status.get('id')));
+    }
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index a092f7bb1..d48b682eb 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -101,6 +101,7 @@ const makeMapStateToProps = () => {
       ancestorsIds,
       descendantsIds,
       askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+      domain: state.getIn(['meta', 'domain']),
     };
   };
 
@@ -123,6 +124,7 @@ class Status extends ImmutablePureComponent {
     descendantsIds: ImmutablePropTypes.list,
     intl: PropTypes.object.isRequired,
     askReplyConfirmation: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
   };
 
   state = {
@@ -387,7 +389,7 @@ class Status extends ImmutablePureComponent {
 
   render () {
     let ancestors, descendants;
-    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl } = this.props;
+    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain } = this.props;
     const { fullscreen } = this.state;
 
     if (status === null) {
@@ -438,6 +440,7 @@ class Status extends ImmutablePureComponent {
                   onOpenVideo={this.handleOpenVideo}
                   onOpenMedia={this.handleOpenMedia}
                   onToggleHidden={this.handleToggleHidden}
+                  domain={domain}
                 />
 
                 <ActionBar
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index ed338c2eb..b7e350cbc 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -33,7 +33,7 @@ const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
 });
 
-const shouldHideFAB = path => path.match(/^\/statuses\//);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
 
 export default @(component => injectIntl(component, { withRef: true }))
 class ColumnsArea extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index a59c0a257..f01c2bf24 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -243,6 +243,7 @@ class UI extends React.PureComponent {
   }
 
   handleDragOver = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return false;
     e.preventDefault();
     e.stopPropagation();
 
@@ -256,6 +257,7 @@ class UI extends React.PureComponent {
   }
 
   handleDrop = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return;
     e.preventDefault();
 
     this.setState({ draggingOver: false });
@@ -279,6 +281,10 @@ class UI extends React.PureComponent {
     this.setState({ draggingOver: false });
   }
 
+  dataTransferIsText = (dataTransfer) => {
+    return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1);
+  }
+
   closeUploadModal = () => {
     this.setState({ draggingOver: false });
   }
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 302ff2573..11c31877c 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -8,7 +8,7 @@
   "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.",
   "account.domain_blocked": "Domini ocult",
   "account.edit_profile": "Editar el perfil",
-  "account.endorse": "Característica del perfil",
+  "account.endorse": "Recomanar en el teu perfil",
   "account.follow": "Segueix",
   "account.followers": "Seguidors",
   "account.followers.empty": "Encara ningú no segueix aquest usuari.",
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index 471b70439..f35b96244 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -84,7 +84,7 @@
   "confirmations.block.confirm": "Blocio",
   "confirmations.block.message": "Ydych chi'n sicr eich bod eisiau blocio {name}?",
   "confirmations.delete.confirm": "Dileu",
-  "confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y statws hwn?",
+  "confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y tŵt hwn?",
   "confirmations.delete_list.confirm": "Dileu",
   "confirmations.delete_list.message": "Ydych chi'n sicr eich bod eisiau dileu y rhestr hwn am byth?",
   "confirmations.domain_block.confirm": "Cuddio parth cyfan",
@@ -92,12 +92,12 @@
   "confirmations.mute.confirm": "Tawelu",
   "confirmations.mute.message": "Ydych chi'n sicr eich bod am ddistewi {name}?",
   "confirmations.redraft.confirm": "Dileu & ailddrafftio",
-  "confirmations.redraft.message": "Ydych chi'n siwr eich bod eisiau dileu y statws hwn a'i ailddrafftio? Bydd ffefrynnau a bwstiau'n cael ei colli, a bydd ymatebion i'r statws gwreiddiol yn cael eu hamddifadu.",
+  "confirmations.redraft.message": "Ydych chi'n siwr eich bod eisiau dileu y tŵt hwn a'i ailddrafftio? Bydd ffefrynnau a bwstiau'n cael ei colli, a bydd ymatebion i'r tŵt gwreiddiol yn cael eu hamddifadu.",
   "confirmations.reply.confirm": "Ateb",
   "confirmations.reply.message": "Bydd ateb nawr yn cymryd lle y neges yr ydych yn cyfansoddi ar hyn o bryd. Ydych chi'n sicr yr ydych am barhau?",
   "confirmations.unfollow.confirm": "Dad-ddilynwch",
   "confirmations.unfollow.message": "Ydych chi'n sicr eich bod am ddad-ddilyn {name}?",
-  "embed.instructions": "Mewnblannwch y statws hwn ar eich gwefan drwy gopïo'r côd isod.",
+  "embed.instructions": "Mewnblannwch y tŵt hwn ar eich gwefan drwy gopïo'r côd isod.",
   "embed.preview": "Dyma sut olwg fydd arno:",
   "emoji_button.activity": "Gweithgarwch",
   "emoji_button.custom": "Unigryw",
@@ -169,12 +169,12 @@
   "keyboard_shortcuts.back": "i lywio nôl",
   "keyboard_shortcuts.blocked": "i agor rhestr defnyddwyr a flociwyd",
   "keyboard_shortcuts.boost": "i fŵstio",
-  "keyboard_shortcuts.column": "i ffocysu statws yn un o'r colofnau",
+  "keyboard_shortcuts.column": "i ffocysu tŵt yn un o'r colofnau",
   "keyboard_shortcuts.compose": "i ffocysu yr ardal cyfansoddi testun",
   "keyboard_shortcuts.description": "Disgrifiad",
   "keyboard_shortcuts.direct": "i agor colofn negeseuon preifat",
   "keyboard_shortcuts.down": "i symud lawr yn y rhestr",
-  "keyboard_shortcuts.enter": "i agor statws",
+  "keyboard_shortcuts.enter": "i agor tŵt",
   "keyboard_shortcuts.favourite": "i hoffi",
   "keyboard_shortcuts.favourites": "i agor rhestr hoffi",
   "keyboard_shortcuts.federated": "i agor ffrwd y ffederasiwn",
@@ -234,10 +234,10 @@
   "navigation_bar.preferences": "Dewisiadau",
   "navigation_bar.public_timeline": "Ffrwd y ffederasiwn",
   "navigation_bar.security": "Diogelwch",
-  "notification.favourite": "hoffodd {name} eich statws",
+  "notification.favourite": "hoffodd {name} eich tŵt",
   "notification.follow": "dilynodd {name} chi",
   "notification.mention": "Soniodd {name} amdanoch chi",
-  "notification.reblog": "{name} boosted your status",
+  "notification.reblog": "Hysbysebodd {name} eich tŵt",
   "notifications.clear": "Clirio hysbysiadau",
   "notifications.clear_confirmation": "Ydych chi'n sicr eich bod am glirio'ch holl hysbysiadau am byth?",
   "notifications.column_settings.alert": "Hysbysiadau bwrdd gwaith",
@@ -257,7 +257,7 @@
   "notifications.filter.follows": "Yn dilyn",
   "notifications.filter.mentions": "Mentions",
   "notifications.group": "{count} o hysbysiadau",
-  "privacy.change": "Addasu preifatrwdd y statws",
+  "privacy.change": "Addasu preifatrwdd y tŵt",
   "privacy.direct.long": "Cyhoeddi i'r defnyddwyr sy'n cael eu crybwyll yn unig",
   "privacy.direct.short": "Uniongyrchol",
   "privacy.private.long": "Cyhoeddi i ddilynwyr yn unig",
@@ -284,7 +284,7 @@
   "search_popout.search_format": "Fformat chwilio uwch",
   "search_popout.tips.full_text": "Mae testun syml yn dychwelyd tŵtiau yr ydych wedi ysgrifennu, hoffi, wedi'u bŵstio, neu wedi'ch crybwyll ynddynt, ynghyd a chyfateb a enwau defnyddwyr, enwau arddangos ac hashnodau.",
   "search_popout.tips.hashtag": "hashnod",
-  "search_popout.tips.status": "statws",
+  "search_popout.tips.status": "tŵt",
   "search_popout.tips.text": "Mae testun syml yn dychwelyd enwau arddangos, enwau defnyddwyr a hashnodau sy'n cyfateb",
   "search_popout.tips.user": "defnyddiwr",
   "search_results.accounts": "Pobl",
@@ -293,7 +293,7 @@
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "Golwg tu fewn...",
   "status.admin_account": "Open moderation interface for @{name}",
-  "status.admin_status": "Open this status in the moderation interface",
+  "status.admin_status": "Open this tŵt in the moderation interface",
   "status.block": "Blocio @{name}",
   "status.cancel_reblog_private": "Dadfŵstio",
   "status.cannot_reblog": "Ni ellir sbarduno'r tŵt hwn",
@@ -309,7 +309,7 @@
   "status.more": "Mwy",
   "status.mute": "Tawelu @{name}",
   "status.mute_conversation": "Tawelu sgwrs",
-  "status.open": "Ehangu'r statws hwn",
+  "status.open": "Ehangu'r tŵt hwn",
   "status.pin": "Pinio ar y proffil",
   "status.pinned": "Pinio tŵt",
   "status.read_more": "Darllen mwy",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index b4181ea05..60f481076 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1973,6 +1973,43 @@
         "id": "confirmations.block.confirm"
       },
       {
+        "defaultMessage": "Reply",
+        "id": "confirmations.reply.confirm"
+      },
+      {
+        "defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
+        "id": "confirmations.reply.message"
+      },
+      {
+        "defaultMessage": "Are you sure you want to block {name}?",
+        "id": "confirmations.block.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/status/containers/detailed_status_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Delete",
+        "id": "confirmations.delete.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to delete this status?",
+        "id": "confirmations.delete.message"
+      },
+      {
+        "defaultMessage": "Delete & redraft",
+        "id": "confirmations.redraft.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
+        "id": "confirmations.redraft.message"
+      },
+      {
+        "defaultMessage": "Block",
+        "id": "confirmations.block.confirm"
+      },
+      {
         "defaultMessage": "Show more for all",
         "id": "status.show_more_all"
       },
diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json
index f213f8ea3..a1a514f49 100644
--- a/app/javascript/mastodon/locales/ro.json
+++ b/app/javascript/mastodon/locales/ro.json
@@ -132,7 +132,7 @@
   "follow_request.authorize": "Autorizează",
   "follow_request.reject": "Respinge",
   "getting_started.developers": "Dezvoltatori",
-  "getting_started.directory": "Directorul profilului",
+  "getting_started.directory": "Explorează",
   "getting_started.documentation": "Documentație",
   "getting_started.heading": "Începe",
   "getting_started.invite": "Invită prieteni",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index 91ecbbce7..a3467785a 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -70,7 +70,7 @@
   "compose_form.direct_message_warning": "Tento príspevok bude videný výhradne iba spomenutými užívateľmi. Ber ale na vedomie že správci tvojej a všetkých iných zahrnutých instancií majú možnosť skontrolovať túto správu.",
   "compose_form.direct_message_warning_learn_more": "Zistiť viac",
   "compose_form.hashtag_warning": "Tento toot nebude zobrazený pod žiadným haštagom lebo nieje listovaný. Iba verejné tooty môžu byť nájdené podľa haštagu.",
-  "compose_form.lock_disclaimer": "Váš účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
+  "compose_form.lock_disclaimer": "Váš účet nie je {locked}. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
   "compose_form.lock_disclaimer.lock": "zamknutý",
   "compose_form.placeholder": "Čo máš na mysli?",
   "compose_form.publish": "Pošli",
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 69441d315..69e6ba0ec 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -91,6 +91,14 @@ function main() {
     if (parallaxComponents.length > 0 ) {
       new Rellax('.parallax', { speed: -1 });
     }
+
+    if (document.body.classList.contains('with-modals')) {
+      const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+      const scrollbarWidthStyle = document.createElement('style');
+      scrollbarWidthStyle.id = 'scrollbar-width';
+      document.head.appendChild(scrollbarWidthStyle);
+      scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0);
+    }
   });
 }
 
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index 47ac9265b..c6f249fab 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -364,7 +364,7 @@ $small-breakpoint: 960px;
 
   @media screen and (max-width: $column-breakpoint) {
     .grid {
-      grid-template-columns: auto;
+      grid-template-columns: 100%;
 
       .column-0 {
         display: block;
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 8de53ca98..a98fa52c4 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -295,7 +295,7 @@
         color: $primary-text-color;
       }
 
-      @media screen and (max-width: $no-gap-breakpoint) {
+      @media screen and (max-width: 550px) {
         &.optional {
           display: none;
         }
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 6132dd1ae..bab982706 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -419,7 +419,7 @@ code {
     background: darken($ui-base-color, 10%) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") no-repeat right 8px center / auto 16px;
     border: 1px solid darken($ui-base-color, 14%);
     border-radius: 4px;
-    padding: 10px;
+    padding-left: 10px;
     padding-right: 30px;
     height: 41px;
   }
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index 87e633c70..c97337e4e 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -425,3 +425,93 @@
     border-radius: 0;
   }
 }
+
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+
+.statuses-grid {
+  min-height: 600px;
+
+  @media screen and (max-width: 640px) {
+    width: 100% !important; // Masonry layout is unnecessary at this width
+  }
+
+  &__item {
+    width: (960px - 20px) / 3;
+
+    @media screen and (max-width: $fluid-breakpoint) {
+      width: (940px - 20px) / 3;
+    }
+
+    @media screen and (max-width: 640px) {
+      width: 100%;
+    }
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      width: 100vw;
+    }
+  }
+
+  .detailed-status {
+    border-radius: 4px;
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      border-top: 1px solid lighten($ui-base-color, 16%);
+    }
+
+    &.compact {
+      .detailed-status__meta {
+        margin-top: 15px;
+      }
+
+      .status__content {
+        font-size: 15px;
+        line-height: 20px;
+
+        .emojione {
+          width: 20px;
+          height: 20px;
+          margin: -3px 0 0;
+        }
+
+        .status__content__spoiler-link {
+          line-height: 20px;
+          margin: 0;
+        }
+      }
+
+      .media-gallery,
+      .status-card,
+      .video-player {
+        margin-top: 15px;
+      }
+    }
+  }
+}
+
+.notice-widget {
+  margin-bottom: 10px;
+  color: $darker-text-color;
+
+  p {
+    margin-bottom: 10px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    font-size: 14px;
+    line-height: 20px;
+    text-decoration: none;
+    font-weight: 500;
+    color: $ui-highlight-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+}