about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/components')
-rw-r--r--app/javascript/flavours/glitch/components/account.js4
-rw-r--r--app/javascript/flavours/glitch/components/admin/Counter.js116
-rw-r--r--app/javascript/flavours/glitch/components/admin/Dimension.js93
-rw-r--r--app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js159
-rw-r--r--app/javascript/flavours/glitch/components/admin/Retention.js151
-rw-r--r--app/javascript/flavours/glitch/components/admin/Trends.js73
-rw-r--r--app/javascript/flavours/glitch/components/attachment_list.js36
-rw-r--r--app/javascript/flavours/glitch/components/avatar_composite.js2
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js6
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js4
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.js8
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.js61
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js34
-rw-r--r--app/javascript/flavours/glitch/components/poll.js27
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js12
-rw-r--r--app/javascript/flavours/glitch/components/skeleton.js11
-rw-r--r--app/javascript/flavours/glitch/components/status.js72
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js14
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js67
-rw-r--r--app/javascript/flavours/glitch/components/status_header.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js31
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js1
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js2
23 files changed, 811 insertions, 179 deletions
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index 20313535b..396a36ea0 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -128,7 +128,7 @@ class Account extends ImmutablePureComponent {
       <Permalink
         className='account small'
         href={account.get('url')}
-        to={`/accounts/${account.get('id')}`}
+        to={`/@${account.get('acct')}`}
       >
         <div className='account__avatar-wrapper'>
           <Avatar
@@ -144,7 +144,7 @@ class Account extends ImmutablePureComponent {
     ) : (
       <div className='account'>
         <div className='account__wrapper'>
-          <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+          <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>
             {mute_expires_at}
             <DisplayName account={account} />
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.js b/app/javascript/flavours/glitch/components/admin/Counter.js
new file mode 100644
index 000000000..2bc9ce482
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Counter.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedNumber } from 'react-intl';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import classNames from 'classnames';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+const percIncrease = (a, b) => {
+  let percent;
+
+  if (b !== 0) {
+    if (a !== 0) {
+      percent = (b - a) / a;
+    } else {
+      percent = 1;
+    }
+  } else if (b === 0 && a === 0) {
+    percent = 0;
+  } else {
+    percent = - 1;
+  }
+
+  return percent;
+};
+
+export default class Counter extends React.PureComponent {
+
+  static propTypes = {
+    measure: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    label: PropTypes.string.isRequired,
+    href: PropTypes.string,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { measure, start_at, end_at, params } = this.props;
+
+    api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, href } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><Skeleton width={43} /></span>
+          <span className='sparkline__value__change'><Skeleton width={43} /></span>
+        </React.Fragment>
+      );
+    } else {
+      const measure = data[0];
+      const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
+
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
+          <span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
+        </React.Fragment>
+      );
+    }
+
+    const inner = (
+      <React.Fragment>
+        <div className='sparkline__value'>
+          {content}
+        </div>
+
+        <div className='sparkline__label'>
+          {label}
+        </div>
+
+        <div className='sparkline__graph'>
+          {!loading && (
+            <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
+              <SparklinesCurve />
+            </Sparklines>
+          )}
+        </div>
+      </React.Fragment>
+    );
+
+    if (href) {
+      return (
+        <a href={href} className='sparkline'>
+          {inner}
+        </a>
+      );
+    } else {
+      return (
+        <div className='sparkline'>
+          {inner}
+        </div>
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.js b/app/javascript/flavours/glitch/components/admin/Dimension.js
new file mode 100644
index 000000000..a924d093c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Dimension.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedNumber } from 'react-intl';
+import { roundTo10 } from 'flavours/glitch/util/numbers';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+export default class Dimension extends React.PureComponent {
+
+  static propTypes = {
+    dimension: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    limit: PropTypes.number.isRequired,
+    label: PropTypes.string.isRequired,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, dimension, limit, params } = this.props;
+
+    api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <table>
+          <tbody>
+            {Array.from(Array(limit)).map((_, i) => (
+              <tr className='dimension__item' key={i}>
+                <td className='dimension__item__key'>
+                  <Skeleton width={100} />
+                </td>
+
+                <td className='dimension__item__value'>
+                  <Skeleton width={60} />
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    } else {
+      const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
+
+      content = (
+        <table>
+          <tbody>
+            {data[0].data.map(item => (
+              <tr className='dimension__item' key={item.key}>
+                <td className='dimension__item__key'>
+                  <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
+                  <span title={item.key}>{item.human_key}</span>
+                </td>
+
+                <td className='dimension__item__value'>
+                  {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    return (
+      <div className='dimension'>
+        <h4>{label}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
new file mode 100644
index 000000000..0f2a4fe36
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  other: { id: 'report.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
+});
+
+class Category extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onSelect: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onSelect } = this.props;
+
+    if (!disabled) {
+      onSelect(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected, children } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
+        {selected && <input type='hidden' name='report[category]' value={id} />}
+
+        <div className='report-reason-selector__category__label'>
+          <span className={classNames('poll__input', { active: selected, disabled })} />
+          {text}
+        </div>
+
+        {(selected && children) && (
+          <div className='report-reason-selector__category__rules'>
+            {children}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+class Rule extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onToggle: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onToggle } = this.props;
+
+    if (!disabled) {
+      onToggle(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
+        <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
+        {selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
+        {text}
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class ReportReasonSelector extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    category: PropTypes.string.isRequired,
+    rule_ids: PropTypes.arrayOf(PropTypes.string),
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    category: this.props.category,
+    rule_ids: this.props.rule_ids || [],
+    rules: [],
+  };
+
+  componentDidMount() {
+    api().get('/api/v1/instance').then(res => {
+      this.setState({
+        rules: res.data.rules,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  _save = () => {
+    const { id, disabled } = this.props;
+    const { category, rule_ids } = this.state;
+
+    if (disabled) {
+      return;
+    }
+
+    api().put(`/api/v1/admin/reports/${id}`, {
+      category,
+      rule_ids,
+    }).catch(err => {
+      console.error(err);
+    });
+  };
+
+  handleSelect = id => {
+    this.setState({ category: id }, () => this._save());
+  };
+
+  handleToggle = id => {
+    const { rule_ids } = this.state;
+
+    if (rule_ids.includes(id)) {
+      this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
+    } else {
+      this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
+    }
+  };
+
+  render () {
+    const { disabled, intl } = this.props;
+    const { rules, category, rule_ids } = this.state;
+
+    return (
+      <div className='report-reason-selector'>
+        <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
+          {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
+        </Category>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Retention.js b/app/javascript/flavours/glitch/components/admin/Retention.js
new file mode 100644
index 000000000..6d7e4b279
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Retention.js
@@ -0,0 +1,151 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
+import classNames from 'classnames';
+import { roundTo10 } from 'flavours/glitch/util/numbers';
+
+const dateForCohort = cohort => {
+  switch(cohort.frequency) {
+  case 'day':
+    return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
+  default:
+    return <FormattedDate value={cohort.period} month='long' year='numeric' />;
+  }
+};
+
+export default class Retention extends React.PureComponent {
+
+  static propTypes = {
+    start_at: PropTypes.string,
+    end_at: PropTypes.string,
+    frequency: PropTypes.string,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, frequency } = this.props;
+
+    api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { loading, data } = this.state;
+    const { frequency } = this.props;
+
+    let content;
+
+    if (loading) {
+      content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
+    } else {
+      content = (
+        <table className='retention__table'>
+          <thead>
+            <tr>
+              <th>
+                <div className='retention__table__date retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
+                </div>
+              </th>
+
+              <th>
+                <div className='retention__table__number retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
+                </div>
+              </th>
+
+              {data[0].data.slice(1).map((retention, i) => (
+                <th key={retention.date}>
+                  <div className='retention__table__number retention__table__label'>
+                    {i + 1}
+                  </div>
+                </th>
+              ))}
+            </tr>
+
+            <tr>
+              <td>
+                <div className='retention__table__date retention__table__average'>
+                  <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
+                </div>
+              </td>
+
+              <td>
+                <div className='retention__table__size'>
+                  <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
+                </div>
+              </td>
+
+              {data[0].data.slice(1).map((retention, i) => {
+                const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
+
+                return (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
+                      <FormattedNumber value={average} style='percent' />
+                    </div>
+                  </td>
+                );
+              })}
+            </tr>
+          </thead>
+
+          <tbody>
+            {data.slice(0, -1).map(cohort => (
+              <tr key={cohort.period}>
+                <td>
+                  <div className='retention__table__date'>
+                    {dateForCohort(cohort)}
+                  </div>
+                </td>
+
+                <td>
+                  <div className='retention__table__size'>
+                    <FormattedNumber value={cohort.data[0].value} />
+                  </div>
+                </td>
+
+                {cohort.data.slice(1).map(retention => (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}>
+                      <FormattedNumber value={retention.rate} style='percent' />
+                    </div>
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    let title = null;
+    switch(frequency) {
+    case 'day':
+      title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
+      break;
+    default:
+      title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
+    };
+
+    return (
+      <div className='retention'>
+        <h4>{title}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js
new file mode 100644
index 000000000..60e367f00
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Trends.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Hashtag from 'flavours/glitch/components/hashtag';
+
+export default class Trends extends React.PureComponent {
+
+  static propTypes = {
+    limit: PropTypes.number.isRequired,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { limit } = this.props;
+
+    api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <div>
+          {Array.from(Array(limit)).map((_, i) => (
+            <Hashtag key={i} />
+          ))}
+        </div>
+      );
+    } else {
+      content = (
+        <div>
+          {data.map(hashtag => (
+            <Hashtag
+              key={hashtag.name}
+              name={hashtag.name}
+              href={`/admin/tags/${hashtag.id}`}
+              people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
+              uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
+              history={hashtag.history.reverse().map(day => day.uses)}
+              className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
+            />
+          ))}
+        </div>
+      );
+    }
+
+    return (
+      <div className='trends trends--compact'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/attachment_list.js b/app/javascript/flavours/glitch/components/attachment_list.js
index 68d8d29c7..68b80b19f 100644
--- a/app/javascript/flavours/glitch/components/attachment_list.js
+++ b/app/javascript/flavours/glitch/components/attachment_list.js
@@ -2,6 +2,8 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 
 const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@@ -16,29 +18,13 @@ export default class AttachmentList extends ImmutablePureComponent {
   render () {
     const { media, compact } = this.props;
 
-    if (compact) {
-      return (
-        <div className='attachment-list compact'>
-          <ul className='attachment-list__list'>
-            {media.map(attachment => {
-              const displayUrl = attachment.get('remote_url') || attachment.get('url');
-
-              return (
-                <li key={attachment.get('id')}>
-                  <a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a>
-                </li>
-              );
-            })}
-          </ul>
-        </div>
-      );
-    }
-
     return (
-      <div className='attachment-list'>
-        <div className='attachment-list__icon'>
-          <Icon id='link' />
-        </div>
+      <div className={classNames('attachment-list', { compact })}>
+        {!compact && (
+          <div className='attachment-list__icon'>
+            <Icon id='link' />
+          </div>
+        )}
 
         <ul className='attachment-list__list'>
           {media.map(attachment => {
@@ -46,7 +32,11 @@ export default class AttachmentList extends ImmutablePureComponent {
 
             return (
               <li key={attachment.get('id')}>
-                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a>
+                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>
+                  {compact && <Icon id='link' />}
+                  {compact && ' ' }
+                  {displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
+                </a>
               </li>
             );
           })}
diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js
index 125b51c44..e30dfe68a 100644
--- a/app/javascript/flavours/glitch/components/avatar_composite.js
+++ b/app/javascript/flavours/glitch/components/avatar_composite.js
@@ -82,7 +82,7 @@ export default class AvatarComposite extends React.PureComponent {
       <a
         href={account.get('url')}
         target='_blank'
-        onClick={(e) => this.props.onAccountClick(account.get('id'), e)}
+        onClick={(e) => this.props.onAccountClick(account.get('acct'), e)}
         title={`@${account.get('acct')}`}
         key={account.get('id')}
       >
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index ccd0714f1..500612093 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -124,8 +124,8 @@ class ColumnHeader extends React.PureComponent {
 
       moveButtons = (
         <div key='move-buttons' className='column-header__setting-arrows'>
-          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
-          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
+          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
+          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
         </div>
       );
     } else if (multiColumn && this.props.onPin) {
@@ -146,8 +146,8 @@ class ColumnHeader extends React.PureComponent {
     ];
 
     if (multiColumn) {
-      collapsedContent.push(moveButtons);
       collapsedContent.push(pinButton);
+      collapsedContent.push(moveButtons);
     }
 
     if (children || (multiColumn && this.props.onPin)) {
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index ad978a2c6..9c7da744e 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -61,7 +61,7 @@ export default class DisplayName extends React.PureComponent {
         <a
           href={a.get('url')}
           target='_blank'
-          onClick={(e) => onAccountClick(a.get('id'), e)}
+          onClick={(e) => onAccountClick(a.get('acct'), e)}
           title={`@${a.get('acct')}`}
           rel='noopener noreferrer'
         >
@@ -76,7 +76,7 @@ export default class DisplayName extends React.PureComponent {
       }
 
       suffix = (
-        <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)} rel='noopener noreferrer'>
+        <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('acct'), e)} rel='noopener noreferrer'>
           <span className='display-name__account'>@{acct}</span>
         </a>
       );
diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js
index 8e6cd1461..4537bde1d 100644
--- a/app/javascript/flavours/glitch/components/error_boundary.js
+++ b/app/javascript/flavours/glitch/components/error_boundary.js
@@ -1,6 +1,7 @@
 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 StackTrace from 'stacktrace-js';
 
@@ -64,6 +65,11 @@ export default class ErrorBoundary extends React.PureComponent {
       debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```';
     }
 
+    let issueTracker = source_url;
+    if (source_url.match(/^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/)) {
+      issueTracker = source_url + '/issues';
+    }
+
     return (
       <div tabIndex='-1'>
         <div className='error-boundary'>
@@ -84,7 +90,7 @@ export default class ErrorBoundary extends React.PureComponent {
               <FormattedMessage
                 id='web_app_crash.report_issue'
                 defaultMessage='Report a bug in the {issuetracker}'
-                values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
+                values={{ issuetracker: <a href={issueTracker} rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
               />
               { debugInfo !== '' && (
                 <details>
diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js
index 24c595ed7..769185a2b 100644
--- a/app/javascript/flavours/glitch/components/hashtag.js
+++ b/app/javascript/flavours/glitch/components/hashtag.js
@@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
 import ShortNumber from 'flavours/glitch/components/short_number';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import classNames from 'classnames';
 
 class SilentErrorBoundary extends React.Component {
 
@@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
   />
 );
 
-const Hashtag = ({ hashtag }) => (
-  <div className='trends__item'>
+export const ImmutableHashtag = ({ hashtag }) => (
+  <Hashtag
+    name={hashtag.get('name')}
+    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()}
+  />
+);
+
+ImmutableHashtag.propTypes = {
+  hashtag: ImmutablePropTypes.map.isRequired,
+};
+
+const Hashtag = ({ name, href, to, people, uses, history, className }) => (
+  <div className={classNames('trends__item', className)}>
     <div className='trends__item__name'>
-      <Permalink
-        href={hashtag.get('url')}
-        to={`/timelines/tag/${hashtag.get('name')}`}
-      >
-        #<span>{hashtag.get('name')}</span>
+      <Permalink href={href} to={to}>
+        {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
       </Permalink>
 
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'accounts']) * 1 +
-          hashtag.getIn(['history', 1, 'accounts']) * 1
-        }
-        renderer={accountsCountRenderer}
-      />
+      {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
     </div>
 
     <div className='trends__item__current'>
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'uses']) * 1 +
-          hashtag.getIn(['history', 1, 'uses']) * 1
-        }
-      />
+      {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
     </div>
 
     <div className='trends__item__sparkline'>
       <SilentErrorBoundary>
-        <Sparklines
-          width={50}
-          height={28}
-          data={hashtag
-            .get('history')
-            .reverse()
-            .map((day) => day.get('uses'))
-            .toArray()}
-        >
+        <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
           <SparklinesCurve style={{ fill: 'none' }} />
         </Sparklines>
       </SilentErrorBoundary>
@@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
 );
 
 Hashtag.propTypes = {
-  hashtag: ImmutablePropTypes.map.isRequired,
+  name: PropTypes.string,
+  href: PropTypes.string,
+  to: PropTypes.string,
+  people: PropTypes.number,
+  uses: PropTypes.number,
+  history: PropTypes.arrayOf(PropTypes.number),
+  className: PropTypes.string,
 };
 
 export default Hashtag;
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index 913234d32..7b5a630e5 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -76,10 +76,13 @@ export default class ModalRoot extends React.PureComponent {
         this.activeElement = null;
       }).catch(console.error);
 
-      this.handleModalClose();
+      this._handleModalClose();
     }
     if (this.props.children && !prevProps.children) {
-      this.handleModalOpen();
+      this._handleModalOpen();
+    }
+    if (this.props.children) {
+      this._ensureHistoryBuffer();
     }
   }
 
@@ -88,22 +91,29 @@ export default class ModalRoot extends React.PureComponent {
     window.removeEventListener('keydown', this.handleKeyDown);
   }
 
-  handleModalClose () {
+  _handleModalOpen () {
+    this._modalHistoryKey = Date.now();
+    this.unlistenHistory = this.history.listen((_, action) => {
+      if (action === 'POP') {
+        this.props.onClose();
+      }
+    });
+  }
+
+  _handleModalClose () {
     this.unlistenHistory();
 
-    const state = this.history.location.state;
-    if (state && state.mastodonModalOpen) {
+    const { state } = this.history.location;
+    if (state && state.mastodonModalKey === this._modalHistoryKey) {
       this.history.goBack();
     }
   }
 
-  handleModalOpen () {
-    const history = this.history;
-    const state   = {...history.location.state, mastodonModalOpen: true};
-    history.push(history.location.pathname, state);
-    this.unlistenHistory = history.listen(() => {
-      this.props.onClose();
-    });
+  _ensureHistoryBuffer () {
+    const { pathname, state } = this.history.location;
+    if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
+      this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
+    }
   }
 
   getSiblings = () => {
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index f230823cc..970b00705 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -12,8 +12,18 @@ import RelativeTimestamp from './relative_timestamp';
 import Icon from 'flavours/glitch/components/icon';
 
 const messages = defineMessages({
-  closed: { id: 'poll.closed', defaultMessage: 'Closed' },
-  voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
+  closed: {
+    id: 'poll.closed',
+    defaultMessage: 'Closed',
+  },
+  voted: {
+    id: 'poll.voted',
+    defaultMessage: 'You voted for this answer',
+  },
+  votes: {
+    id: 'poll.votes',
+    defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
+  },
 });
 
 const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
@@ -148,9 +158,16 @@ class Poll extends ImmutablePureComponent {
               data-index={optionIndex}
             />
           )}
-          {showResults && <span className='poll__number'>
-            {Math.round(percent)}%
-          </span>}
+          {showResults && (
+            <span
+              className='poll__number'
+              title={intl.formatMessage(messages.votes, {
+                votes: option.get('votes_count'),
+              })}
+            >
+              {Math.round(percent)}%
+            </span>
+          )}
 
           <span
             className='poll__option__text translate'
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index cc8d9f1f3..16f13afa4 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -1,5 +1,5 @@
 import React, { PureComponent } from 'react';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 import PropTypes from 'prop-types';
 import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
 import LoadMore from './load_more';
@@ -34,7 +34,6 @@ class ScrollableList extends PureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     showLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
@@ -264,11 +263,6 @@ class ScrollableList extends PureComponent {
     this.props.onLoadMore();
   }
 
-  defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
-    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
-    return !(location.state && location.state.mastodonModalOpen);
-  }
-
   handleLoadPending = e => {
     e.preventDefault();
     this.props.onLoadPending();
@@ -282,7 +276,7 @@ class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -348,7 +342,7 @@ class ScrollableList extends PureComponent {
 
     if (trackScroll) {
       return (
-        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll || this.defaultShouldUpdateScroll}>
+        <ScrollContainer scrollKey={scrollKey}>
           {scrollableArea}
         </ScrollContainer>
       );
diff --git a/app/javascript/flavours/glitch/components/skeleton.js b/app/javascript/flavours/glitch/components/skeleton.js
new file mode 100644
index 000000000..09093e99c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/skeleton.js
@@ -0,0 +1,11 @@
+import React from 'react';
+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,
+};
+
+export default Skeleton;
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 782fd918e..02ff9ab28 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -346,7 +346,9 @@ class Status extends ImmutablePureComponent {
         return;
       } else {
         if (destination === undefined) {
-          destination = `/statuses/${
+          destination = `/@${
+            status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
+          }/${
             status.getIn(['reblog', 'id'], status.get('id'))
           }`;
         }
@@ -362,16 +364,6 @@ class Status extends ImmutablePureComponent {
     this.setState({ showMedia: !this.state.showMedia });
   }
 
-  handleAccountClick = (e) => {
-    if (this.context.router && e.button === 0) {
-      const id = e.currentTarget.getAttribute('data-id');
-      e.preventDefault();
-      let state = {...this.context.router.history.location.state};
-      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${id}`, state);
-    }
-  }
-
   handleExpandedToggle = () => {
     if (this.props.status.get('spoiler_text')) {
       this.setExpansion(!this.state.isExpanded);
@@ -433,13 +425,14 @@ class Status extends ImmutablePureComponent {
   handleHotkeyOpen = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
+    const status = this.props.status;
+    this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`, state);
   }
 
   handleHotkeyOpenProfile = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
   }
 
   handleHotkeyMoveUp = e => {
@@ -516,8 +509,8 @@ class Status extends ImmutablePureComponent {
     const { isExpanded, isCollapsed, forceFilter } = this.state;
     let background = null;
     let attachments = null;
-    let media = null;
-    let mediaIcon = null;
+    let media = [];
+    let mediaIcons = [];
 
     if (status === null) {
       return null;
@@ -543,9 +536,8 @@ class Status extends ImmutablePureComponent {
       return (
         <HotKeys handlers={handlers}>
           <div ref={this.handleRef} className='status focusable' tabIndex='0'>
-            {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
-            {' '}
-            {status.get('content')}
+            <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
+            <span>{status.get('content')}</span>
           </div>
         </HotKeys>
       );
@@ -587,25 +579,27 @@ class Status extends ImmutablePureComponent {
     //  After we have generated our appropriate media element and stored it in
     //  `media`, we snatch the thumbnail to use as our `background` if media
     //  backgrounds for collapsed statuses are enabled.
+
     attachments = status.get('media_attachments');
     if (status.get('poll')) {
-      media = <PollContainer pollId={status.get('poll')} />;
-      mediaIcon = 'tasks';
-    } else if (usingPiP) {
-      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
-      mediaIcon = 'video-camera';
+      media.push(<PollContainer pollId={status.get('poll')} />);
+      mediaIcons.push('tasks');
+    }
+    if (usingPiP) {
+      media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
+      mediaIcons.push('video-camera');
     } else if (attachments.size > 0) {
       if (muted || attachments.some(item => item.get('type') === 'unknown')) {
-        media = (
+        media.push(
           <AttachmentList
             compact
             media={status.get('media_attachments')}
-          />
+          />,
         );
       } else if (attachments.getIn([0, 'type']) === 'audio') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
             {Component => (
               <Component
@@ -622,13 +616,13 @@ class Status extends ImmutablePureComponent {
                 deployPictureInPicture={this.handleDeployPictureInPicture}
               />
             )}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'music';
+        mediaIcons.push('music');
       } else if (attachments.getIn([0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
             {Component => (<Component
               preview={attachment.get('preview_url')}
@@ -648,11 +642,11 @@ class Status extends ImmutablePureComponent {
               visible={this.state.showMedia}
               onToggleVisibility={this.handleToggleMediaVisibility}
             />)}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'video-camera';
+        mediaIcons.push('video-camera');
       } else {  //  Media type is 'image' or 'gifv'
-        media = (
+        media.push(
           <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
             {Component => (
               <Component
@@ -668,16 +662,16 @@ class Status extends ImmutablePureComponent {
                 onToggleVisibility={this.handleToggleMediaVisibility}
               />
             )}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'picture-o';
+        mediaIcons.push('picture-o');
       }
 
       if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
         background = attachments.getIn([0, 'preview_url']);
       }
     } else if (status.get('card') && settings.get('inline_preview_cards')) {
-      media = (
+      media.push(
         <Card
           onOpenMedia={this.handleOpenMedia}
           card={status.get('card')}
@@ -685,9 +679,9 @@ class Status extends ImmutablePureComponent {
           cacheWidth={this.props.cacheMediaWidth}
           defaultWidth={this.props.cachedMediaWidth}
           sensitive={status.get('sensitive')}
-        />
+        />,
       );
-      mediaIcon = 'link';
+      mediaIcons.push('link');
     }
 
     //  Here we prepare extra data-* attributes for CSS selectors.
@@ -754,7 +748,7 @@ class Status extends ImmutablePureComponent {
             </span>
             <StatusIcons
               status={status}
-              mediaIcon={mediaIcon}
+              mediaIcons={mediaIcons}
               collapsible={settings.getIn(['collapsed', 'enabled'])}
               collapsed={isCollapsed}
               setCollapsed={setCollapsed}
@@ -764,7 +758,7 @@ class Status extends ImmutablePureComponent {
           <StatusContent
             status={status}
             media={media}
-            mediaIcon={mediaIcon}
+            mediaIcons={mediaIcons}
             expanded={isExpanded}
             onExpandedToggle={this.handleExpandedToggle}
             parseClick={parseClick}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 74bfd948e..ae67c6116 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -38,6 +38,7 @@ const messages = defineMessages({
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
   hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
+  edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
 });
 
 export default @injectIntl
@@ -147,11 +148,11 @@ class StatusActionBar extends ImmutablePureComponent {
 
   handleOpen = () => {
     let state = {...this.context.router.history.location.state};
-    if (state.mastodonModalOpen) {
-      this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
+    if (state.mastodonModalKey) {
+      this.context.router.history.replace(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
     } else {
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, state);
     }
   }
 
@@ -196,6 +197,7 @@ class StatusActionBar extends ImmutablePureComponent {
     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;
 
     let menu = [];
@@ -212,7 +214,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
     menu.push(null);
 
-    if (writtenByMe && publicStatus) {
+    if (writtenByMe && pinnableStatus) {
       menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
       menu.push(null);
     }
@@ -323,7 +325,9 @@ class StatusActionBar extends ImmutablePureComponent {
           </div>,
         ]}
 
-        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
+          <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
+        </a>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 34ff97305..1d32b35e5 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -69,8 +69,8 @@ export default class StatusContent extends React.PureComponent {
     expanded: PropTypes.bool,
     collapsed: PropTypes.bool,
     onExpandedToggle: PropTypes.func,
-    media: PropTypes.element,
-    mediaIcon: PropTypes.string,
+    media: PropTypes.node,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
     parseClick: PropTypes.func,
     disabled: PropTypes.bool,
     onUpdate: PropTypes.func,
@@ -197,7 +197,7 @@ export default class StatusContent extends React.PureComponent {
 
   onMentionClick = (mention, e) => {
     if (this.props.parseClick) {
-      this.props.parseClick(e, `/accounts/${mention.get('id')}`);
+      this.props.parseClick(e, `/@${mention.get('acct')}`);
     }
   }
 
@@ -205,7 +205,7 @@ export default class StatusContent extends React.PureComponent {
     hashtag = hashtag.replace(/^#/, '');
 
     if (this.props.parseClick) {
-      this.props.parseClick(e, `/timelines/tag/${hashtag}`);
+      this.props.parseClick(e, `/tags/${hashtag}`);
     }
   }
 
@@ -224,8 +224,8 @@ export default class StatusContent extends React.PureComponent {
     const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
 
     let element = e.target;
-    while (element) {
-      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName)) {
+    while (element !== e.currentTarget) {
+      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName) || element.getAttribute('role') === 'button') {
         return;
       }
       element = element.parentNode;
@@ -256,7 +256,7 @@ export default class StatusContent extends React.PureComponent {
     const {
       status,
       media,
-      mediaIcon,
+      mediaIcons,
       parseClick,
       disabled,
       tagLinks,
@@ -277,7 +277,7 @@ export default class StatusContent extends React.PureComponent {
 
       const mentionLinks = status.get('mentions').map(item => (
         <Permalink
-          to={`/accounts/${item.get('id')}`}
+          to={`/@${item.get('acct')}`}
           href={item.get('url')}
           key={item.get('id')}
           className='mention'
@@ -286,28 +286,37 @@ export default class StatusContent extends React.PureComponent {
         </Permalink>
       )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
 
-      const toggleText = hidden ? [
-        <FormattedMessage
-          id='status.show_more'
-          defaultMessage='Show more'
-          key='0'
-        />,
-        mediaIcon ? (
-          <Icon
-            fixedWidth
-            className='status__content__spoiler-icon'
-            id={mediaIcon}
-            aria-hidden='true'
-            key='1'
+      let toggleText = null;
+      if (hidden) {
+        toggleText = [
+          <FormattedMessage
+            id='status.show_more'
+            defaultMessage='Show more'
+            key='0'
+          />,
+        ];
+        if (mediaIcons) {
+          mediaIcons.forEach((mediaIcon, idx) => {
+            toggleText.push(
+              <Icon
+                fixedWidth
+                className='status__content__spoiler-icon'
+                id={mediaIcon}
+                aria-hidden='true'
+                key={`icon-${idx}`}
+              />,
+            );
+          });
+        }
+      } else {
+        toggleText = (
+          <FormattedMessage
+            id='status.show_less'
+            defaultMessage='Show less'
+            key='0'
           />
-        ) : null,
-      ] : [
-        <FormattedMessage
-          id='status.show_less'
-          defaultMessage='Show less'
-          key='0'
-        />,
-      ];
+        );
+      }
 
       if (hidden) {
         mentionsPlaceholder = <div>{mentionLinks}</div>;
diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js
index 06296e124..cc476139b 100644
--- a/app/javascript/flavours/glitch/components/status_header.js
+++ b/app/javascript/flavours/glitch/components/status_header.js
@@ -19,14 +19,14 @@ export default class StatusHeader extends React.PureComponent {
   };
 
   //  Handles clicks on account name/image
-  handleClick = (id, e) => {
+  handleClick = (acct, e) => {
     const { parseClick } = this.props;
-    parseClick(e, `/accounts/${id}`);
+    parseClick(e, `/@${acct}`);
   }
 
   handleAccountClick = (e) => {
     const { status } = this.props;
-    this.handleClick(status.getIn(['account', 'id']), e);
+    this.handleClick(status.getIn(['account', 'acct']), e);
   }
 
   //  Rendering.
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index f4d0a7405..e66947f4a 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -27,7 +27,7 @@ class StatusIcons extends React.PureComponent {
 
   static propTypes = {
     status: ImmutablePropTypes.map.isRequired,
-    mediaIcon: PropTypes.string,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
     collapsible: PropTypes.bool,
     collapsed: PropTypes.bool,
     directMessage: PropTypes.bool,
@@ -44,8 +44,8 @@ class StatusIcons extends React.PureComponent {
     }
   }
 
-  mediaIconTitleText () {
-    const { intl, mediaIcon } = this.props;
+  mediaIconTitleText (mediaIcon) {
+    const { intl } = this.props;
 
     switch (mediaIcon) {
       case 'link':
@@ -61,11 +61,24 @@ class StatusIcons extends React.PureComponent {
     }
   }
 
+  renderIcon (mediaIcon) {
+    return (
+      <Icon
+        fixedWidth
+        className='status__media-icon'
+        key={`media-icon--${mediaIcon}`}
+        id={mediaIcon}
+        aria-hidden='true'
+        title={this.mediaIconTitleText(mediaIcon)}
+      />
+    );
+  }
+
   //  Rendering.
   render () {
     const {
       status,
-      mediaIcon,
+      mediaIcons,
       collapsible,
       collapsed,
       directMessage,
@@ -90,15 +103,7 @@ class StatusIcons extends React.PureComponent {
             aria-hidden='true'
             title={intl.formatMessage(messages.localOnly)}
           />}
-        {mediaIcon ? (
-          <Icon
-            fixedWidth
-            className='status__media-icon'
-            id={mediaIcon}
-            aria-hidden='true'
-            title={this.mediaIconTitleText()}
-          />
-        ) : null}
+        { !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon)) }
         {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
         {collapsible ? (
           <IconButton
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index 60cc23f4b..9095e087e 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -18,7 +18,6 @@ export default class StatusList extends ImmutablePureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     isPartial: PropTypes.bool,
     hasMore: PropTypes.bool,
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index af6acdef9..5a00f232e 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -17,7 +17,7 @@ export default class StatusPrepend extends React.PureComponent {
 
   handleClick = (e) => {
     const { account, parseClick } = this.props;
-    parseClick(e, `/accounts/${account.get('id')}`);
+    parseClick(e, `/@${account.get('acct')}`);
   }
 
   Message = () => {