about summary refs log tree commit diff
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2020-09-27 17:09:25 -0500
committerStarfall <us@starfall.systems>2020-09-27 17:09:25 -0500
commit4966c6cc24107c728fe8e0de7ffc4d0a8cc5510d (patch)
treea31455093fda701c6746559299b0a5e742ed4ce7
parentbc32e736fbd7c6afd59e619c885fa80ab637324a (diff)
parent787d5d728923393f12421a480b3c7aee789a11fe (diff)
Merge branch 'glitch' into main
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js14
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js9
-rw-r--r--app/javascript/flavours/glitch/components/status.js3
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js6
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow_request.js6
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js7
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js38
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js5
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js59
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss25
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss4
12 files changed, 136 insertions, 42 deletions
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index 96e29accf..6b49ebf88 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -105,15 +105,15 @@ export function submitMarkers() {
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
-    const params = { timeline: ['notifications'] };
+  const params = { timeline: ['notifications'] };
 
-    dispatch(fetchMarkersRequest());
+  dispatch(fetchMarkersRequest());
 
-    api(getState).get('/api/v1/markers', { params }).then(response => {
-      dispatch(fetchMarkersSuccess(response.data));
-    }).catch(error => {
-      dispatch(fetchMarkersFail(error));
-    });
+  api(getState).get('/api/v1/markers', { params }).then(response => {
+    dispatch(fetchMarkersSuccess(response.data));
+  }).catch(error => {
+    dispatch(fetchMarkersFail(error));
+  });
 };
 
 export function fetchMarkersRequest() {
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index ceb1e6df6..ccc427c29 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -18,6 +18,7 @@ import compareId from 'flavours/glitch/util/compare_id';
 import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
 
 // tracking the notif cleaning request
 export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
@@ -45,6 +46,8 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
 export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
 
+export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
 });
@@ -318,3 +321,9 @@ export function setFilter (filterType) {
     dispatch(saveSettings());
   };
 };
+
+export function markNotificationsAsRead() {
+  return {
+    type: NOTIFICATIONS_MARK_AS_READ,
+  };
+};
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 4e628a420..8da5db961 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -122,6 +122,7 @@ class Status extends ImmutablePureComponent {
     'notification',
     'hidden',
     'expanded',
+    'unread',
   ]
 
   updateOnStates = [
@@ -694,7 +695,7 @@ class Status extends ImmutablePureComponent {
       collapsed: isCollapsed,
       'has-background': isCollapsed && background,
       'status__wrapper-reply': !!status.get('in_reply_to_id'),
-      read: unread === false,
+      unread,
       muted,
     }, 'focusable');
 
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index d8a51c689..b4549fdf8 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -159,7 +159,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
     listItems = listItems.concat([
       <div key='9'>
         <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
-        {lists.map(list =>
+        {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list =>
           <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
         )}
       </div>,
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js
index 2b71f3107..0d3162fc9 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
 
 // Our imports.
 import Permalink from 'flavours/glitch/components/permalink';
@@ -19,6 +20,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
     id: PropTypes.string.isRequired,
     account: ImmutablePropTypes.map.isRequired,
     notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
   };
 
   handleMoveUp = () => {
@@ -59,7 +61,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, notification, hidden } = this.props;
+    const { account, notification, hidden, unread } = this.props;
 
     //  Links to the display name.
     const displayName = account.get('display_name_html') || account.get('username');
@@ -76,7 +78,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
     //  Renders.
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-follow focusable' tabIndex='0'>
+        <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0'>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon fixedWidth id='user-plus' />
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
index d73dac434..f351c1035 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
@@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import NotificationOverlayContainer from '../containers/overlay_container';
 import { HotKeys } from 'react-hotkeys';
 import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
@@ -25,6 +26,7 @@ class FollowRequest extends ImmutablePureComponent {
     onReject: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
   };
 
   handleMoveUp = () => {
@@ -65,7 +67,7 @@ class FollowRequest extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, hidden, account, onAuthorize, onReject, notification } = this.props;
+    const { intl, hidden, account, onAuthorize, onReject, notification, unread } = this.props;
 
     if (!account) {
       return <div />;
@@ -94,7 +96,7 @@ class FollowRequest extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-follow-request focusable' tabIndex='0'>
+        <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0'>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='user' fixedWidth />
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index 62fc28386..bd415856c 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -22,6 +22,7 @@ export default class Notification extends ImmutablePureComponent {
     cacheMediaWidth: PropTypes.func,
     cachedMediaWidth: PropTypes.number,
     onUnmount: PropTypes.func,
+    unread: PropTypes.bool,
   };
 
   render () {
@@ -46,6 +47,7 @@ export default class Notification extends ImmutablePureComponent {
           onMoveDown={onMoveDown}
           onMoveUp={onMoveUp}
           onMention={onMention}
+          unread={this.props.unread}
         />
       );
     case 'follow_request':
@@ -58,6 +60,7 @@ export default class Notification extends ImmutablePureComponent {
           onMoveDown={onMoveDown}
           onMoveUp={onMoveUp}
           onMention={onMention}
+          unread={this.props.unread}
         />
       );
     case 'mention':
@@ -77,6 +80,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     case 'favourite':
@@ -98,6 +102,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     case 'reblog':
@@ -119,6 +124,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     case 'poll':
@@ -140,6 +146,7 @@ export default class Notification extends ImmutablePureComponent {
           cacheMediaWidth={this.props.cacheMediaWidth}
           onUnmount={this.props.onUnmount}
           withDismiss
+          unread={this.props.unread}
         />
       );
     default:
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 26710feff..681323860 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -12,8 +12,10 @@ import {
   mountNotifications,
   unmountNotifications,
   loadPending,
+  markNotificationsAsRead,
 } from 'flavours/glitch/actions/notifications';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { submitMarkers } from 'flavours/glitch/actions/markers';
 import NotificationContainer from './containers/notification_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -24,12 +26,14 @@ import { debounce } from 'lodash';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import LoadGap from 'flavours/glitch/components/load_gap';
 import Icon from 'flavours/glitch/components/icon';
+import compareId from 'flavours/glitch/util/compare_id';
 
 import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
   enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
+  markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
 });
 
 const getNotifications = createSelector([
@@ -56,6 +60,8 @@ const mapStateToProps = state => ({
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
+  lastReadId: state.getIn(['notifications', 'readMarkerId']),
+  canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
 });
 
 /* glitch */
@@ -63,6 +69,10 @@ const mapDispatchToProps = dispatch => ({
   onEnterCleaningMode(yes) {
     dispatch(enterNotificationClearingMode(yes));
   },
+  onMarkAsRead() {
+    dispatch(markNotificationsAsRead());
+    dispatch(submitMarkers());
+  },
   onMount() {
     dispatch(mountNotifications());
   },
@@ -93,6 +103,8 @@ class Notifications extends React.PureComponent {
     onEnterCleaningMode: PropTypes.func,
     onMount: PropTypes.func,
     onUnmount: PropTypes.func,
+    lastReadId: PropTypes.string,
+    canMarkAsRead: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -194,8 +206,12 @@ class Notifications extends React.PureComponent {
     this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
   }
 
+  handleMarkAsRead = () => {
+    this.props.onMarkAsRead();
+  }
+
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
     const { notifCleaning, notifCleaningActive } = this.props;
     const { animatingNCD } = this.state;
     const pinned = !!columnId;
@@ -224,6 +240,7 @@ class Notifications extends React.PureComponent {
           accountId={item.get('account')}
           onMoveUp={this.handleMoveUp}
           onMoveDown={this.handleMoveDown}
+          unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
         />
       ));
     } else {
@@ -252,6 +269,21 @@ class Notifications extends React.PureComponent {
       </ScrollableList>
     );
 
+    const extraButtons = [];
+
+    if (canMarkAsRead) {
+      extraButtons.push(
+        <button
+          aria-label={intl.formatMessage(messages.markAsRead)}
+          title={intl.formatMessage(messages.markAsRead)}
+          onClick={this.handleMarkAsRead}
+          className='column-header__button'
+        >
+          <Icon id='check' />
+        </button>
+      );
+    }
+
     const notifCleaningButtonClassName = classNames('column-header__button', {
       'active': notifCleaningActive,
     });
@@ -263,7 +295,7 @@ class Notifications extends React.PureComponent {
 
     const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
 
-    const notifCleaningButton = (
+    extraButtons.push(
       <button
         aria-label={msgEnterNotifCleaning}
         title={msgEnterNotifCleaning}
@@ -300,7 +332,7 @@ class Notifications extends React.PureComponent {
           pinned={pinned}
           multiColumn={multiColumn}
           localSettings={this.props.localSettings}
-          extraButton={notifCleaningButton}
+          extraButton={extraButtons}
           appendContent={notifCleaningDrawer}
         >
           <ColumnSettingsContainer />
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index bf76c0e57..a8fc1ad84 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -12,7 +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 { synchronouslySubmitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
+import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
 import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import PermaLink from 'flavours/glitch/components/permalink';
@@ -358,6 +358,9 @@ class UI extends React.Component {
   handleVisibilityChange = () => {
     const visibility = !document[this.visibilityHiddenProp];
     this.props.dispatch(notificationsSetVisibility(visibility));
+    if (visibility) {
+      this.props.dispatch(submitMarkers());
+    }
   }
 
   componentWillMount () {
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index 31d9611a3..474ca3012 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -16,6 +16,7 @@ import {
   NOTIFICATIONS_DELETE_MARKED_FAIL,
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+  NOTIFICATIONS_MARK_AS_READ,
 } from 'flavours/glitch/actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -39,6 +40,7 @@ const initialState = ImmutableMap({
   mounted: 0,
   unread: 0,
   lastReadId: '0',
+  readMarkerId: '0',
   isLoading: false,
   cleaningMode: false,
   isTabVisible: true,
@@ -55,16 +57,16 @@ const notificationToMap = (state, notification) => ImmutableMap({
 });
 
 const normalizeNotification = (state, notification, usePendingItems) => {
-  const top = !shouldCountUnreadNotifications(state);
+  const top = state.get('top');
 
   if (usePendingItems || !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 {
+  if (shouldCountUnreadNotifications(state)) {
     state = state.update('unread', unread => unread + 1);
+  } else {
+    state = state.set('lastReadId', notification.id);
   }
 
   return state.update('items', list => {
@@ -77,7 +79,6 @@ const normalizeNotification = (state, notification, usePendingItems) => {
 };
 
 const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => {
-  const top = !(shouldCountUnreadNotifications(state));
   const lastReadId = state.get('lastReadId');
   let items = ImmutableList();
 
@@ -102,18 +103,19 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
       });
     }
 
-    if (top) {
-      if (!items.isEmpty()) {
-        mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id'));
-      }
-    } else {
-      mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0));
-    }
-
     if (!next) {
       mutable.set('hasMore', false);
     }
 
+    if (shouldCountUnreadNotifications(state)) {
+      mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0));
+    } else {
+      const mostRecent = items.find(item => item !== null);
+      if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {
+        mutable.set('lastReadId', mostRecent.get('id'));
+      }
+    }
+
     mutable.set('isLoading', false);
   });
 };
@@ -127,7 +129,7 @@ const clearUnread = (state) => {
   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');
-}
+};
 
 const updateTop = (state, top) => {
   state = state.set('top', top);
@@ -136,16 +138,17 @@ const updateTop = (state, top) => {
     state = clearUnread(state);
   }
 
-  return state.set('top', top);
+  return state;
 };
 
 const deleteByStatus = (state, statusId) => {
-  const top = !(shouldCountUnreadNotifications(state));
-  if (!top) {
-    const lastReadId = state.get('lastReadId');
+  const lastReadId = state.get('lastReadId');
+
+  if (shouldCountUnreadNotifications(state)) {
     const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
     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);
@@ -183,6 +186,7 @@ const deleteMarkedNotifs = (state) => {
 const updateMounted = (state) => {
   state = state.update('mounted', count => count + 1);
   if (!shouldCountUnreadNotifications(state)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
   return state;
@@ -191,13 +195,20 @@ const updateMounted = (state) => {
 const updateVisibility = (state, visibility) => {
   state = state.set('isTabVisible', visibility);
   if (!shouldCountUnreadNotifications(state)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
   return state;
 };
 
 const shouldCountUnreadNotifications = (state) => {
-  return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0);
+  const isTabVisible   = state.get('isTabVisible');
+  const isOnTop        = state.get('top');
+  const isMounted      = state.get('mounted') > 0;
+  const lastReadId     = state.get('lastReadId');
+  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0);
+
+  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
 };
 
 const recountUnread = (state, last_read_id) => {
@@ -206,11 +217,15 @@ const recountUnread = (state, last_read_id) => {
       mutable.set('lastReadId', last_read_id);
     }
 
+    if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) {
+      mutable.set('readMarkerId', last_read_id);
+    }
+
     if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) {
       mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0));
     }
   });
-}
+};
 
 export default function notifications(state = initialState, action) {
   let st;
@@ -284,6 +299,10 @@ export default function notifications(state = initialState, action) {
     }
     return markAllForDelete(st, action.yes);
 
+  case NOTIFICATIONS_MARK_AS_READ:
+    const lastNotification = state.get('items').find(item => item !== null);
+    return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
+
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 0b1f50dbc..b70ff00f1 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -275,7 +275,7 @@
     outline: 0;
     background: lighten($ui-base-color, 4%);
 
-    &.status.status-direct:not(.read) {
+    &.status.status-direct {
       background: lighten($ui-base-color, 12%);
 
       &.muted {
@@ -316,7 +316,7 @@
     margin-top: 8px;
   }
 
-  &.status-direct:not(.read) {
+  &.status-direct {
     background: lighten($ui-base-color, 8%);
     border-bottom-color: lighten($ui-base-color, 12%);
   }
@@ -399,7 +399,7 @@
     &:focus > .status__content:after {
       background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));
     }
-    &.status-direct:not(.read)> .status__content:after {
+    &.status-direct > .status__content:after {
       background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));
     }
 
@@ -1062,3 +1062,22 @@ a.status-card.compact:hover {
     text-decoration: underline;
   }
 }
+
+.notification,
+.status {
+  position: relative;
+
+  &.unread {
+    &::before {
+      content: "";
+      position: absolute;
+      top: 0;
+      left: 0;
+      pointer-events: 0;
+      width: 100%;
+      height: 100%;
+      border-left: 2px solid $highlight-text-color;
+      pointer-events: none;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index c83c82766..e5a5cc246 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -27,7 +27,7 @@
   }
 }
 
-.status.status-direct:not(.read) {
+.status.status-direct {
   background: darken($ui-base-color, 8%);
   border-bottom-color: darken($ui-base-color, 12%);
 
@@ -36,7 +36,7 @@
   }
 }
 
-.focusable:focus.status.status-direct:not(.read) {
+.focusable:focus.status.status-direct {
   background: darken($ui-base-color, 4%);
 
   &.collapsed> .status__content:after {