about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
authorReverite <github@reverite.sh>2019-09-18 16:38:31 -0700
committerReverite <github@reverite.sh>2019-09-18 16:38:31 -0700
commit73f4af117fc5b5e4ee3337d634f5e589a9353644 (patch)
tree62b0443573ba5038fd45aa14d4243dfc832c060a /app/javascript/flavours
parent849e2bc4ca11892e701835a4696904d78b1ad325 (diff)
parentfebcdad2e2c98aee62b55ee21bdf0debf7c6fd6b (diff)
Merge branch 'glitch' into production
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js30
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js5
-rw-r--r--app/javascript/flavours/glitch/components/poll.js40
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js11
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js2
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js2
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/index.js3
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js13
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.js2
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js12
-rw-r--r--app/javascript/flavours/glitch/locales/pt-PT.js (renamed from app/javascript/flavours/glitch/locales/pt.js)2
-rw-r--r--app/javascript/flavours/glitch/packs/public.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js18
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js10
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss44
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss11
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss51
22 files changed, 224 insertions, 55 deletions
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
new file mode 100644
index 000000000..c3a5fe86f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -0,0 +1,30 @@
+export const submitMarkers = () => (dispatch, getState) => {
+  const accessToken = getState().getIn(['meta', 'access_token'], '');
+  const params      = {};
+
+  const lastHomeId         = getState().getIn(['timelines', 'home', 'items', 0]);
+  const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']);
+
+  if (lastHomeId) {
+    params.home = {
+      last_read_id: lastHomeId,
+    };
+  }
+
+  if (lastNotificationId) {
+    params.notifications = {
+      last_read_id: lastNotificationId,
+    };
+  }
+
+  if (Object.keys(params).length === 0) {
+    return;
+  }
+
+  const client = new XMLHttpRequest();
+
+  client.open('POST', '/api/v1/markers', false);
+  client.setRequestHeader('Content-Type', 'application/json');
+  client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
+  client.send(JSON.stringify(params));
+};
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 0c2331374..be48b1c77 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -165,7 +165,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
       dispatch(importFetchedAccounts(response.data.map(item => item.account)));
       dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
 
-      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent && preferPendingItems));
+      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
       fetchRelatedRelationships(dispatch, response.data);
       done();
     }).catch(error => {
@@ -182,11 +182,12 @@ export function expandNotificationsRequest(isLoadingMore) {
   };
 };
 
-export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) {
+export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
   return {
     type: NOTIFICATIONS_EXPAND_SUCCESS,
     notifications,
     next,
+    isLoadingRecent: isLoadingRecent,
     usePendingItems,
     skipLoading: !isLoadingMore,
   };
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index 36c4b236c..905aa54c1 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -32,8 +32,38 @@ class Poll extends ImmutablePureComponent {
 
   state = {
     selected: {},
+    expired: null,
   };
 
+  static getDerivedStateFromProps (props, state) {
+    const { poll, intl } = props;
+    const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now();
+    return (expired === state.expired) ? null : { expired };
+  }
+
+  componentDidMount () {
+    this._setupTimer();
+  }
+
+  componentDidUpdate () {
+    this._setupTimer();
+  }
+
+  componentWillUnmount () {
+    clearTimeout(this._timer);
+  }
+
+  _setupTimer () {
+    const { poll, intl } = this.props;
+    clearTimeout(this._timer);
+    if (!this.state.expired) {
+      const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
+      this._timer = setTimeout(() => {
+        this.setState({ expired: true });
+      }, delay);
+    }
+  }
+
   handleOptionChange = e => {
     const { target: { value } } = e;
 
@@ -68,12 +98,11 @@ class Poll extends ImmutablePureComponent {
     this.props.dispatch(fetchPoll(this.props.poll.get('id')));
   };
 
-  renderOption (option, optionIndex) {
+  renderOption (option, optionIndex, showResults) {
     const { poll, disabled } = this.props;
     const percent            = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100;
     const leading            = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
     const active             = !!this.state.selected[`${optionIndex}`];
-    const showResults        = poll.get('voted') || poll.get('expired');
 
     let titleEmojified = option.get('title_emojified');
     if (!titleEmojified) {
@@ -112,19 +141,20 @@ class Poll extends ImmutablePureComponent {
 
   render () {
     const { poll, intl } = this.props;
+    const { expired } = this.state;
 
     if (!poll) {
       return null;
     }
 
-    const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
-    const showResults   = poll.get('voted') || poll.get('expired');
+    const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
+    const showResults   = poll.get('voted') || expired;
     const disabled      = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
 
     return (
       <div className='poll'>
         <ul>
-          {poll.get('options').map((option, i) => this.renderOption(option, i))}
+          {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
         </ul>
 
         <div className='poll__footer'>
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index 5f42bdd8b..7c0b6d082 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -153,7 +153,9 @@ export default class ScrollableList extends PureComponent {
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
       this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
-    if (someItemInserted && (this.node.scrollTop > 0 || this.mouseMovedRecently)) {
+    const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
+
+    if (pendingChanged || someItemInserted && (this.node.scrollTop > 0 || this.mouseMovedRecently)) {
       return this.node.scrollHeight - this.node.scrollTop;
     } else {
       return null;
@@ -228,6 +230,13 @@ export default class ScrollableList extends PureComponent {
   handleLoadPending = e => {
     e.preventDefault();
     this.props.onLoadPending();
+    // Prevent the weird scroll-jumping behavior, as we explicitly don't want to
+    // scroll to top, and we know the scroll height is going to change
+    this.scrollToTopOnMouseIdle = false;
+    this.lastScrollWasSynthetic = false;
+    this.clearMouseIdleTimer();
+    this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
+    this.mouseMovedRecently = true;
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index af86010f0..d99b25e25 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -93,7 +93,7 @@ class StatusIcons extends React.PureComponent {
         {mediaIcon ? (
           <Icon
             fixedWidth
-            className='status__media-icon`'
+            className='status__media-icon'
             id={mediaIcon}
             aria-hidden='true'
             title={this.mediaIconTitleText()}
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index bded66d09..15eb4f85f 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -108,7 +108,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     dispatch((_, getState) => {
       let state = getState();
       if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
-        dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog, missingMediaDescription: true }));
       } else if (e.shiftKey || !boostModal) {
         this.onModalReblog(status);
       } else {
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index fc0202129..0d131bd35 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -256,7 +256,7 @@ class Header extends ImmutablePureComponent {
             <div className='account__header__tabs__buttons'>
               {actionBtn}
 
-              <DropdownMenuContainer items={menu} id='ellipsis-v' size={24} direction='right' />
+              <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
             </div>
           </div>
 
diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js
index cb7d72c53..5585edc9c 100644
--- a/app/javascript/flavours/glitch/features/community_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/community_timeline/index.js
@@ -18,9 +18,10 @@ const mapStateToProps = (state, { onlyMedia, columnId }) => {
   const uuid = columnId;
   const columns = state.getIn(['settings', 'columns']);
   const index = columns.findIndex(c => c.get('uuid') === uuid);
+  const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
 
   return {
-    hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0,
+    hasUnread: !!timelineState && (timelineState.get('unread') > 0 || timelineState.get('pendingItems').size > 0),
     onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']),
   };
 };
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
index 60fee9b7f..404504e84 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -77,9 +77,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
   }
 
-  handleClick = (e) => {
-    const name = e.currentTarget.getAttribute('data-index');
-
+  handleClick = (name, e) => {
     const {
       onChange,
       onClose,
@@ -103,9 +101,8 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     }
   }
 
-  handleKeyDown = e => {
+  handleKeyDown = (name, e) => {
     const { items } = this.props;
-    const name = e.currentTarget.getAttribute('data-index');
     const index = items.findIndex(item => {
       return (item.name === name);
     });
@@ -183,7 +180,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     let prefix = null;
 
     if (on !== null && typeof on !== 'undefined') {
-      prefix = <Toggle checked={on} onChange={this.handleClick} />;
+      prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />;
     } else if (icon) {
       prefix = <Icon className='icon' fixedWidth id={icon} />
     }
@@ -191,8 +188,8 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     return (
       <div
         className={computedClass}
-        onClick={this.handleClick}
-        onKeyDown={this.handleKeyDown}
+        onClick={this.handleClick.bind(this, name)}
+        onKeyDown={this.handleKeyDown.bind(this, name)}
         role='option'
         tabIndex='0'
         key={name}
diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
index d0678f2f0..3d818ea20 100644
--- a/app/javascript/flavours/glitch/features/compose/components/poll_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
@@ -79,7 +79,7 @@ class Option extends React.PureComponent {
         </label>
 
         <div className='poll__cancel'>
-          <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} id='times' onClick={this.handleOptionRemove} />
+          <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} />
         </div>
       </li>
     );
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index bd1af97a9..99b2391c7 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -47,7 +47,7 @@ const mapStateToProps = state => ({
   notifications: getNotifications(state),
   localSettings:  state.get('local_settings'),
   isLoading: state.getIn(['notifications', 'isLoading'], true),
-  isUnread: state.getIn(['notifications', 'unread']) > 0,
+  isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index 7d1deb4ce..8bded391a 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -223,10 +223,10 @@ class FocalPointModal extends ImmutablePureComponent {
 
             <div className='setting-text__toolbar'>
               <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
-              <CharacterCounter max={420} text={detecting ? '' : description} />
+              <CharacterCounter max={1500} text={detecting ? '' : description} />
             </div>
 
-            <Button disabled={!dirty || detecting || length(description) > 420} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
+            <Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
           </div>
 
           <div className='focal-point-modal__content'>
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 1feda0b97..6cc1b1cde 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -12,6 +12,7 @@ import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
 import { fetchFilters } from 'flavours/glitch/actions/filters';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
+import { submitMarkers } from 'flavours/glitch/actions/markers';
 import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
@@ -63,6 +64,7 @@ const messages = defineMessages({
 const mapStateToProps = state => ({
   hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
   hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
+  canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
   layout: state.getIn(['local_settings', 'layout']),
   isWide: state.getIn(['local_settings', 'stretch']),
   navbarUnder: state.getIn(['local_settings', 'navbar_under']),
@@ -229,6 +231,7 @@ class UI extends React.Component {
     isComposing: PropTypes.bool,
     hasComposingText: PropTypes.bool,
     hasMediaAttachments: PropTypes.bool,
+    canUploadMore: PropTypes.bool,
     match: PropTypes.object.isRequired,
     location: PropTypes.object.isRequired,
     history: PropTypes.object.isRequired,
@@ -243,7 +246,9 @@ class UI extends React.Component {
   };
 
   handleBeforeUnload = (e) => {
-    const { intl, hasComposingText, hasMediaAttachments } = this.props;
+    const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
+
+    dispatch(submitMarkers());
 
     if (hasComposingText || hasMediaAttachments) {
       // Setting returnValue to any string causes confirmation dialog.
@@ -269,7 +274,7 @@ class UI extends React.Component {
       this.dragTargets.push(e.target);
     }
 
-    if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+    if (e.dataTransfer && e.dataTransfer.types.includes('Files') && this.props.canUploadMore) {
       this.setState({ draggingOver: true });
     }
   }
@@ -290,12 +295,13 @@ class UI extends React.Component {
 
   handleDrop = (e) => {
     if (this.dataTransferIsText(e.dataTransfer)) return;
+
     e.preventDefault();
 
     this.setState({ draggingOver: false });
     this.dragTargets = [];
 
-    if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
+    if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore) {
       this.props.dispatch(uploadCompose(e.dataTransfer.files));
     }
   }
diff --git a/app/javascript/flavours/glitch/locales/pt.js b/app/javascript/flavours/glitch/locales/pt-PT.js
index 0156f55ff..cf7afd17a 100644
--- a/app/javascript/flavours/glitch/locales/pt.js
+++ b/app/javascript/flavours/glitch/locales/pt-PT.js
@@ -1,4 +1,4 @@
-import inherited from 'mastodon/locales/pt.json';
+import inherited from 'mastodon/locales/pt-PT.json';
 
 const messages = {
   //  No translations available.
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index 72725d20b..019de2167 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -11,10 +11,10 @@ function main() {
   const React = require('react');
   const ReactDOM = require('react-dom');
   const Rellax = require('rellax');
-  const createHistory = require('history').createBrowserHistory;
+  const { createBrowserHistory } = require('history');
 
   const scrollToDetailedStatus = () => {
-    const history = createHistory();
+    const history = createBrowserHistory();
     const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
     const location = history.location;
 
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index 135995da6..8dc7a4aba 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -50,12 +50,12 @@ const notificationToMap = (state, notification) => ImmutableMap({
 });
 
 const normalizeNotification = (state, notification, usePendingItems) => {
-  if (usePendingItems) {
-    return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification)));
-  }
-
   const top = !shouldCountUnreadNotifications(state);
 
+  if (usePendingItems || !top || !state.get('pendingItems').isEmpty()) {
+    return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification))).update('unread', unread => unread + 1);
+  }
+
   if (top) {
     state = state.set('lastReadId', notification.id);
   } else {
@@ -71,7 +71,7 @@ const normalizeNotification = (state, notification, usePendingItems) => {
   });
 };
 
-const expandNormalizedNotifications = (state, notifications, next, usePendingItems) => {
+const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => {
   const top = !(shouldCountUnreadNotifications(state));
   const lastReadId = state.get('lastReadId');
   let items = ImmutableList();
@@ -82,6 +82,8 @@ const expandNormalizedNotifications = (state, notifications, next, usePendingIte
 
   return state.withMutations(mutable => {
     if (!items.isEmpty()) {
+      usePendingItems = isLoadingRecent && (usePendingItems || !mutable.get('top') || !mutable.get('pendingItems').isEmpty());
+
       mutable.update(usePendingItems ? 'pendingItems' : 'items', list => {
         const lastIndex = 1 + list.findLastIndex(
           item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
@@ -117,7 +119,7 @@ const filterNotifications = (state, accountIds) => {
 };
 
 const clearUnread = (state) => {
-  state = state.set('unread', 0);
+  state = state.set('unread', state.get('pendingItems').size);
   const lastNotification = state.get('items').find(item => item !== null);
   return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
 }
@@ -140,6 +142,8 @@ const deleteByStatus = (state, statusId) => {
     state = state.update('unread', unread => unread - deletedUnread.size);
   }
   const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
+  const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+  state = state.update('unread', unread => unread - deletedUnread.size);
   return state.update('items', helper).update('pendingItems', helper);
 };
 
@@ -216,7 +220,7 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_UPDATE:
     return normalizeNotification(state, action.notification, action.usePendingItems);
   case NOTIFICATIONS_EXPAND_SUCCESS:
-    return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems);
+    return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingRecent, action.usePendingItems);
   case ACCOUNT_BLOCK_SUCCESS:
     return filterNotifications(state, [action.relationship.id]);
   case ACCOUNT_MUTE_SUCCESS:
diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js
index 9b016a4c6..e6bef18e9 100644
--- a/app/javascript/flavours/glitch/reducers/timelines.js
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -40,6 +40,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
     if (timeline.endsWith(':pinned')) {
       mMap.set('items', statuses.map(status => status.get('id')));
     } else if (!statuses.isEmpty()) {
+      usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('top') || !mMap.get('pendingItems').isEmpty());
       mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => {
         const newIds = statuses.map(status => status.get('id'));
         const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
@@ -59,15 +60,16 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
 };
 
 const updateTimeline = (state, timeline, status, usePendingItems) => {
-  if (usePendingItems) {
+  const top = state.getIn([timeline, 'top']);
+
+  if (usePendingItems || !top || !state.getIn([timeline, 'pendingItems']).isEmpty()) {
     if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) {
       return state;
     }
 
-    return state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id'))));
+    return state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id'))).update('unread', unread => unread + 1));
   }
 
-  const top        = state.getIn([timeline, 'top']);
   const ids        = state.getIn([timeline, 'items'], ImmutableList());
   const includesId = ids.includes(status.get('id'));
   const unread     = state.getIn([timeline, 'unread'], 0);
@@ -127,7 +129,7 @@ const filterTimeline = (timeline, state, relationship, statuses) => {
 
 const updateTop = (state, timeline, top) => {
   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
-    if (top) mMap.set('unread', 0);
+    if (top) mMap.set('unread', mMap.get('pendingItems').size);
     mMap.set('top', top);
   }));
 };
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index 0fae137f0..a827d271a 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -226,6 +226,7 @@
 }
 
 .account__header__fields {
+  max-width: 100vw;
   padding: 0;
   margin: 15px -15px -15px;
   border: 0 none;
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 74f91599a..089ae68c0 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -720,3 +720,47 @@ a.name-tag,
   text-overflow: ellipsis;
   vertical-align: middle;
 }
+
+.admin-account-bio {
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 -5px;
+  margin-top: 20px;
+
+  > div {
+    box-sizing: border-box;
+    padding: 0 5px;
+    margin-bottom: 10px;
+    flex: 1 0 50%;
+  }
+
+  .account__header__fields,
+  .account__header__content {
+    background: lighten($ui-base-color, 8%);
+    border-radius: 4px;
+    height: 100%;
+  }
+
+  .account__header__fields {
+    margin: 0;
+    border: 0;
+
+    a {
+      color: lighten($ui-highlight-color, 8%);
+    }
+
+    dl:first-child .verified {
+      border-radius: 0 4px 0 0;
+    }
+
+    .verified a {
+      color: $valid-value-color;
+    }
+  }
+
+  .account__header__content {
+    box-sizing: border-box;
+    padding: 20px;
+    color: $primary-text-color;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 45eb5a9d0..17455ca58 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -759,16 +759,6 @@
     }
   }
 
-  .static-icon-button {
-    color: $action-button-color;
-    font-size: 18px;
-
-    & > span {
-      font-size: 14px;
-      font-weight: 500;
-    }
-  }
-
   .directory__list {
     display: grid;
     grid-gap: 10px;
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
index 5261f17f4..06f60408d 100644
--- a/app/javascript/flavours/glitch/styles/polls.scss
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -11,7 +11,6 @@
   li {
     margin-bottom: 10px;
     position: relative;
-    height: 18px + 12px;
   }
 
   &__chart {
@@ -30,13 +29,11 @@
 
   &__text {
     position: relative;
-    display: inline-block;
+    display: flex;
     padding: 6px 0;
     line-height: 18px;
     cursor: default;
-    white-space: nowrap;
     overflow: hidden;
-    text-overflow: ellipsis;
 
     input[type=radio],
     input[type=checkbox] {
@@ -89,6 +86,9 @@
     top: -1px;
     border-radius: 50%;
     vertical-align: middle;
+    margin-top: auto;
+    margin-bottom: auto;
+    flex: 0 0 18px;
 
     &.checkbox {
       border-radius: 4px;
@@ -106,6 +106,9 @@
     font-weight: 700;
     padding: 0 10px;
     text-align: right;
+    margin-top: auto;
+    margin-bottom: auto;
+    flex: 0 0 36px;
   }
 
   &__footer {
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
index bf67388f0..669f72787 100644
--- a/app/javascript/flavours/glitch/styles/tables.scss
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -180,6 +180,18 @@ a.table-action-link {
     }
   }
 
+  &__form {
+    padding: 16px;
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    background: $ui-base-color;
+
+    .fields-row {
+      padding-top: 0;
+      margin-bottom: 0;
+    }
+  }
+
   &__row {
     border: 1px solid darken($ui-base-color, 8%);
     border-top: 0;
@@ -210,6 +222,45 @@ a.table-action-link {
       &--unpadded {
         padding: 0;
       }
+
+      &--with-image {
+        display: flex;
+        align-items: center;
+      }
+
+      &__image {
+        flex: 0 0 auto;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        margin-right: 10px;
+
+        .emojione {
+          width: 32px;
+          height: 32px;
+        }
+      }
+
+      &__text {
+        flex: 1 1 auto;
+      }
+
+      &__extra {
+        flex: 0 0 auto;
+        text-align: right;
+        color: $darker-text-color;
+        font-weight: 500;
+      }
+    }
+
+    .directory__tag {
+      margin: 0;
+      width: 100%;
+
+      a {
+        background: transparent;
+        border-radius: 0;
+      }
     }
   }