about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features')
-rw-r--r--app/javascript/flavours/glitch/features/audio/index.js64
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/language_dropdown.js19
-rw-r--r--app/javascript/flavours/glitch/features/filters/added_to_filter.js102
-rw-r--r--app/javascript/flavours/glitch/features/filters/select_filter.js192
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js23
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/filter_modal.js134
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js3
9 files changed, 493 insertions, 50 deletions
diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js
index ac0468f70..8fa64342e 100644
--- a/app/javascript/flavours/glitch/features/audio/index.js
+++ b/app/javascript/flavours/glitch/features/audio/index.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
 import { formatTime } from 'flavours/glitch/features/video';
 import Icon from 'flavours/glitch/components/icon';
 import classNames from 'classnames';
@@ -8,6 +8,9 @@ import { throttle } from 'lodash';
 import { getPointerPosition, fileNameFromURL } from 'flavours/glitch/features/video';
 import { debounce } from 'lodash';
 import Visualizer from './visualizer';
+import { displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state';
+import Blurhash from 'flavours/glitch/components/blurhash';
+import { is } from 'immutable';
 
 const messages = defineMessages({
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -15,6 +18,7 @@ const messages = defineMessages({
   mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
   unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
   download: { id: 'video.download', defaultMessage: 'Download file' },
+  hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
 });
 
 const TICK_SIZE = 10;
@@ -30,10 +34,14 @@ class Audio extends React.PureComponent {
     duration: PropTypes.number,
     width: PropTypes.number,
     height: PropTypes.number,
+    sensitive: PropTypes.bool,
     editable: PropTypes.bool,
     fullscreen: PropTypes.bool,
     intl: PropTypes.object.isRequired,
+    blurhash: PropTypes.string,
     cacheWidth: PropTypes.func,
+    visible: PropTypes.bool,
+    onToggleVisibility: PropTypes.func,
     backgroundColor: PropTypes.string,
     foregroundColor: PropTypes.string,
     accentColor: PropTypes.string,
@@ -53,6 +61,7 @@ class Audio extends React.PureComponent {
     muted: false,
     volume: 0.5,
     dragging: false,
+    revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
   };
 
   constructor (props) {
@@ -78,6 +87,8 @@ class Audio extends React.PureComponent {
       backgroundColor: this.props.backgroundColor,
       foregroundColor: this.props.foregroundColor,
       accentColor: this.props.accentColor,
+      sensitive: this.props.sensitive,
+      visible: this.props.visible,
     };
   }
 
@@ -132,6 +143,12 @@ class Audio extends React.PureComponent {
     }
   }
 
+  componentWillReceiveProps (nextProps) {
+    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+      this.setState({ revealed: nextProps.visible });
+    }
+  }
+
   componentWillUnmount () {
     window.removeEventListener('scroll', this.handleScroll);
     window.removeEventListener('resize', this.handleResize);
@@ -195,6 +212,14 @@ class Audio extends React.PureComponent {
     });
   }
 
+  toggleReveal = () => {
+    if (this.props.onToggleVisibility) {
+      this.props.onToggleVisibility();
+    } else {
+      this.setState({ revealed: !this.state.revealed });
+    }
+  }
+
   handleVolumeMouseDown = e => {
     document.addEventListener('mousemove', this.handleMouseVolSlide, true);
     document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
@@ -439,13 +464,29 @@ class Audio extends React.PureComponent {
   }
 
   render () {
-    const { src, intl, alt, editable, autoPlay } = this.props;
-    const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
+    const { src, intl, alt, editable, autoPlay, sensitive, blurhash } = this.props;
+    const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
     const progress = Math.min((currentTime / duration) * 100, 100);
 
+    let warning;
+    if (sensitive) {
+      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
+    } else {
+      warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
+    }
+
     return (
-      <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
-        <audio
+      <div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
+
+        <Blurhash
+          hash={blurhash}
+          className={classNames('media-gallery__preview', {
+            'media-gallery__preview--hidden': revealed,
+          })}
+          dummy={!useBlurhash}
+        />
+
+        {(revealed || editable) && <audio
           src={src}
           ref={this.setAudioRef}
           preload={autoPlay ? 'auto' : 'none'}
@@ -454,7 +495,7 @@ class Audio extends React.PureComponent {
           onProgress={this.handleProgress}
           onLoadedData={this.handleLoadedData}
           crossOrigin='anonymous'
-        />
+        />}
 
         <canvas
           role='button'
@@ -470,13 +511,19 @@ class Audio extends React.PureComponent {
           aria-label={alt}
         />
 
-        <img
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
+          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
+            <span className='spoiler-button__overlay__label'>{warning}</span>
+          </button>
+        </div>
+
+        {(revealed || editable) && <img
           src={this.props.poster}
           alt=''
           width={(this._getRadius() - TICK_SIZE) * 2}
           height={(this._getRadius() - TICK_SIZE) * 2}
           style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
-        />
+        />}
 
         <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
           <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
@@ -514,6 +561,7 @@ class Audio extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
+              {!editable && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
               <a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
                 <Icon id={'download'} fixedWidth />
               </a>
diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js
index c8c503e58..035b0c0c3 100644
--- a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js
+++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js
@@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
 import { supportsPassiveEvents } from 'detect-passive-events';
 import classNames from 'classnames';
 import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state';
+import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons';
 import fuzzysort from 'fuzzysort';
 
 const messages = defineMessages({
@@ -16,22 +17,6 @@ const messages = defineMessages({
   clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
 });
 
-// Copied from emoji-mart for consistency with emoji picker and since
-// they don't export the icons in the package
-const icons = {
-  loupe: (
-    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
-      <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
-    </svg>
-  ),
-
-  delete: (
-    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
-      <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
-    </svg>
-  ),
-};
-
 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 class LanguageDropdownMenu extends React.PureComponent {
@@ -242,7 +227,7 @@ class LanguageDropdownMenu extends React.PureComponent {
           <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
             <div className='emoji-mart-search'>
               <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
-              <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? icons.loupe : icons.delete}</button>
+              <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
             </div>
 
             <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
diff --git a/app/javascript/flavours/glitch/features/filters/added_to_filter.js b/app/javascript/flavours/glitch/features/filters/added_to_filter.js
new file mode 100644
index 000000000..f777ca429
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/filters/added_to_filter.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'flavours/glitch/util/filters';
+import Button from 'flavours/glitch/components/button';
+import { connect } from 'react-redux';
+
+const mapStateToProps = (state, { filterId }) => ({
+  filter: state.getIn(['filters', filterId]),
+});
+
+export default @connect(mapStateToProps)
+class AddedToFilter extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    contextType: PropTypes.string,
+    filter: ImmutablePropTypes.map.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleCloseClick = () => {
+    const { onClose } = this.props;
+    onClose();
+  };
+
+  render () {
+    const { filter, contextType } = this.props;
+
+    let expiredMessage = null;
+    if (filter.get('expires_at') && filter.get('expires_at') < new Date()) {
+      expiredMessage = (
+        <React.Fragment>
+          <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.expired_title' defaultMessage='Expired filter!' /></h4>
+          <p className='report-dialog-modal__lead'>
+            <FormattedMessage
+              id='filter_modal.added.expired_explanation'
+              defaultMessage='This filter category has expired, you will need to change the expiration date for it to apply.'
+            />
+          </p>
+        </React.Fragment>
+      );
+    }
+
+    let contextMismatchMessage = null;
+    if (contextType && !filter.get('context').includes(toServerSideType(contextType))) {
+      contextMismatchMessage = (
+        <React.Fragment>
+          <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.context_mismatch_title' defaultMessage='Context mismatch!' /></h4>
+          <p className='report-dialog-modal__lead'>
+            <FormattedMessage
+              id='filter_modal.added.context_mismatch_explanation'
+              defaultMessage='This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.'
+            />
+          </p>
+        </React.Fragment>
+      );
+    }
+
+    const settings_link = (
+      <a href={`/filters/${filter.get('id')}/edit`}>
+        <FormattedMessage
+          id='filter_modal.added.settings_link'
+          defaultMessage='settings page'
+        />
+      </a>
+    );
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.added.title' defaultMessage='Filter added!' /></h3>
+        <p className='report-dialog-modal__lead'>
+          <FormattedMessage
+            id='filter_modal.added.short_explanation'
+            defaultMessage='This post has been added to the following filter category: {title}.'
+            values={{ title: filter.get('title') }}
+          />
+        </p>
+
+        {expiredMessage}
+        {contextMismatchMessage}
+
+        <h4 className='report-dialog-modal__subtitle'><FormattedMessage id='filter_modal.added.review_and_configure_title' defaultMessage='Filter settings' /></h4>
+        <p className='report-dialog-modal__lead'>
+          <FormattedMessage
+            id='filter_modal.added.review_and_configure'
+            defaultMessage='To review and further configure this filter category, go to the {settings_link}.'
+            values={{ settings_link }}
+          />
+        </p>
+
+        <div className='flex-spacer' />
+
+        <div className='report-dialog-modal__actions'>
+          <Button onClick={this.handleCloseClick}><FormattedMessage id='report.close' defaultMessage='Done' /></Button>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/filters/select_filter.js b/app/javascript/flavours/glitch/features/filters/select_filter.js
new file mode 100644
index 000000000..5321dbb96
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/filters/select_filter.js
@@ -0,0 +1,192 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { toServerSideType } from 'flavours/glitch/util/filters';
+import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons';
+import Icon from 'flavours/glitch/components/icon';
+import fuzzysort from 'fuzzysort';
+
+const messages = defineMessages({
+  search: { id: 'filter_modal.select_filter.search', defaultMessage: 'Search or create' },
+  clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
+});
+
+const mapStateToProps = (state, { contextType }) => ({
+  filters: Array.from(state.get('filters').values()).map((filter) => [
+    filter.get('id'),
+    filter.get('title'),
+    filter.get('keywords')?.map((keyword) => keyword.get('keyword')).join('\n'),
+    filter.get('expires_at') && filter.get('expires_at') < new Date(),
+    contextType && !filter.get('context').includes(toServerSideType(contextType)),
+  ]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class SelectFilter extends React.PureComponent {
+
+  static propTypes = {
+    onSelectFilter: PropTypes.func.isRequired,
+    onNewFilter: PropTypes.func.isRequired,
+    filters: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.object)),
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    searchValue: '',
+  };
+
+  search () {
+    const { filters } = this.props;
+    const { searchValue } = this.state;
+
+    if (searchValue === '') {
+      return filters;
+    }
+
+    return fuzzysort.go(searchValue, filters, {
+      keys: ['1', '2'],
+      limit: 5,
+      threshold: -10000,
+    }).map(result => result.obj);
+  }
+
+  renderItem = filter => {
+    let warning = null;
+    if (filter[3] || filter[4]) {
+      warning = (
+        <span className='language-dropdown__dropdown__results__item__common-name'>
+          (
+          {filter[3] && <FormattedMessage id='filter_modal.select_filter.expired' defaultMessage='expired' />}
+          {filter[3] && filter[4] && ', '}
+          {filter[4] && <FormattedMessage id='filter_modal.select_filter.context_mismatch' defaultMessage='does not apply to this context' />}
+          )
+        </span>
+      );
+    }
+
+    return (
+      <div key={filter[0]} role='button' tabIndex='0' data-index={filter[0]} className='language-dropdown__dropdown__results__item' onClick={this.handleItemClick} onKeyDown={this.handleKeyDown}>
+        <span className='language-dropdown__dropdown__results__item__native-name'>{filter[1]}</span> {warning}
+      </div>
+    );
+  }
+
+  renderCreateNew (name) {
+    return (
+      <div key='add-new-filter' role='button' tabIndex='0' className='language-dropdown__dropdown__results__item' onClick={this.handleNewFilterClick} onKeyDown={this.handleKeyDown}>
+        <Icon id='plus' fixedWidth /> <FormattedMessage id='filter_modal.select_filter.prompt_new' defaultMessage='New category: {name}' values={{ name }} />
+      </div>
+    );
+  }
+
+  handleSearchChange = ({ target }) => {
+    this.setState({ searchValue: target.value });
+  }
+
+  setListRef = c => {
+    this.listNode = c;
+  }
+
+  handleKeyDown = e => {
+    const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
+
+    let element = null;
+
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      e.currentTarget.click();
+      break;
+    case 'ArrowDown':
+      element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      } else {
+        element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.listNode.firstChild;
+      break;
+    case 'End':
+      element = this.listNode.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  }
+
+  handleSearchKeyDown = e => {
+    let element = null;
+
+    switch(e.key) {
+    case 'Tab':
+    case 'ArrowDown':
+      element = this.listNode.firstChild;
+
+      if (element) {
+        element.focus();
+        e.preventDefault();
+        e.stopPropagation();
+      }
+
+      break;
+    }
+  }
+
+  handleClear = () => {
+    this.setState({ searchValue: '' });
+  }
+
+  handleItemClick = e => {
+    const value = e.currentTarget.getAttribute('data-index');
+
+    e.preventDefault();
+
+    this.props.onSelectFilter(value);
+  }
+
+  handleNewFilterClick = e => {
+    e.preventDefault();
+
+    this.props.onNewFilter(this.state.searchValue);
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    const { searchValue } = this.state;
+    const isSearching = searchValue !== '';
+    const results = this.search();
+
+    return (
+      <React.Fragment>
+        <h3 className='report-dialog-modal__title'><FormattedMessage id='filter_modal.select_filter.title' defaultMessage='Filter this post' /></h3>
+        <p className='report-dialog-modal__lead'><FormattedMessage id='filter_modal.select_filter.subtitle' defaultMessage='Use an existing category or create a new one' /></p>
+
+        <div className='emoji-mart-search'>
+          <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
+          <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
+        </div>
+
+        <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
+          {results.map(this.renderItem)}
+          {isSearching && this.renderCreateNew(searchValue) }
+        </div>
+
+      </React.Fragment>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index ffa4e3409..333b73b45 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -24,10 +24,6 @@ const messages = defineMessages({
   side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' },
   side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' },
   regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' },
-  filters_drop: { id: 'settings.filtering_behavior.drop', defaultMessage: 'Hide filtered toots completely' },
-  filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' },
-  filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' },
-  filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' },
   rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' },
   rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' },
   rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage:  'Rewrite with username' },
@@ -358,25 +354,6 @@ class LocalSettingsPage extends React.PureComponent {
         </section>
       </div>
     ),
-    ({ intl, onChange, settings }) => (
-      <div className='glitch local-settings__page filters'>
-        <h1><FormattedMessage id='settings.filters' defaultMessage='Filters' /></h1>
-        <LocalSettingsPageItem
-          settings={settings}
-          item={['filtering_behavior']}
-          id='mastodon-settings--filters-behavior'
-          onChange={onChange}
-          options={[
-            { value: 'drop', message: intl.formatMessage(messages.filters_drop) },
-            { value: 'upstream', message: intl.formatMessage(messages.filters_upstream) },
-            { value: 'hide', message: intl.formatMessage(messages.filters_hide) },
-            { value: 'content_warning', message: intl.formatMessage(messages.filters_cw) }
-          ]}
-        >
-          <FormattedMessage id='settings.filtering_behavior' defaultMessage='Filtering behavior' />
-        </LocalSettingsPageItem>
-      </div>
-    ),
     ({ onChange, settings }) => (
       <div className='glitch local-settings__page collapsed'>
         <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 301a2add6..91dc5ba20 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -165,7 +165,11 @@ class DetailedStatus extends ImmutablePureComponent {
             backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
             foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
             accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
+            sensitive={status.get('sensitive')}
+            visible={this.props.showMedia}
+            blurhash={attachment.get('blurhash')}
             height={150}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
           />,
         );
         mediaIcons.push('music');
diff --git a/app/javascript/flavours/glitch/features/ui/components/filter_modal.js b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js
new file mode 100644
index 000000000..d2482e733
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/filter_modal.js
@@ -0,0 +1,134 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { fetchStatus } from 'flavours/glitch/actions/statuses';
+import { fetchFilters, createFilter, createFilterStatus } from 'flavours/glitch/actions/filters';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import IconButton from 'flavours/glitch/components/icon_button';
+import SelectFilter from 'flavours/glitch/features/filters/select_filter';
+import AddedToFilter from 'flavours/glitch/features/filters/added_to_filter';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @connect(undefined)
+@injectIntl
+class FilterModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    contextType: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    step: 'select',
+    filterId: null,
+    isSubmitting: false,
+    isSubmitted: false,
+  };
+
+  handleNewFilterSuccess = (result) => {
+    this.handleSelectFilter(result.id);
+  };
+
+  handleSuccess = () => {
+    const { dispatch, statusId } = this.props;
+    dispatch(fetchStatus(statusId, true));
+    this.setState({ isSubmitting: false, isSubmitted: true, step: 'submitted' });
+  };
+
+  handleFail = () => {
+    this.setState({ isSubmitting: false });
+  };
+
+  handleNextStep = step => {
+    this.setState({ step });
+  };
+
+  handleSelectFilter = (filterId) => {
+    const { dispatch, statusId } = this.props;
+
+    this.setState({ isSubmitting: true, filterId });
+
+    dispatch(createFilterStatus({
+      filter_id: filterId,
+      status_id: statusId,
+    }, this.handleSuccess, this.handleFail));
+  };
+
+  handleNewFilter = (title) => {
+    const { dispatch } = this.props;
+
+    this.setState({ isSubmitting: true });
+
+    dispatch(createFilter({
+      title,
+      context: ['home', 'notifications', 'public', 'thread', 'account'],
+      action: 'warn',
+    }, this.handleNewFilterSuccess, this.handleFail));
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(fetchFilters());
+  }
+
+  render () {
+    const {
+      intl,
+      statusId,
+      contextType,
+      onClose,
+    } = this.props;
+
+    const {
+      step,
+      filterId,
+    } = this.state;
+
+    let stepComponent;
+
+    switch(step) {
+    case 'select':
+      stepComponent = (
+        <SelectFilter
+          contextType={contextType}
+          onSelectFilter={this.handleSelectFilter}
+          onNewFilter={this.handleNewFilter}
+        />
+      );
+      break;
+    case 'create':
+      stepComponent = null;
+      break;
+    case 'submitted':
+      stepComponent = (
+        <AddedToFilter
+          contextType={contextType}
+          filterId={filterId}
+          statusId={statusId}
+          onClose={onClose}
+        />
+      );
+    }
+
+    return (
+      <div className='modal-root__modal report-dialog-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
+          <FormattedMessage id='filter_modal.title.status' defaultMessage='Filter a post' />
+        </div>
+
+        <div className='report-dialog-modal__container'>
+          {stepComponent}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
index 8f18d93b7..4df3a0dee 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -26,6 +26,7 @@ import {
   ListAdder,
   PinnedAccountsEditor,
   CompareHistoryModal,
+  FilterModal,
 } from 'flavours/glitch/util/async-components';
 
 const MODAL_COMPONENTS = {
@@ -49,6 +50,7 @@ const MODAL_COMPONENTS = {
   'LIST_ADDER': ListAdder,
   'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
   'COMPARE_HISTORY': CompareHistoryModal,
+  'FILTER': FilterModal,
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 099ffc69c..2be6d9478 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -10,7 +10,6 @@ import { debounce } from 'lodash';
 import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
 import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
-import { fetchFilters } from 'flavours/glitch/actions/filters';
 import { fetchRules } from 'flavours/glitch/actions/rules';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
@@ -402,7 +401,7 @@ class UI extends React.Component {
     this.props.dispatch(fetchMarkers());
     this.props.dispatch(expandHomeTimeline());
     this.props.dispatch(expandNotifications());
-    setTimeout(() => this.props.dispatch(fetchFilters()), 500);
+
     setTimeout(() => this.props.dispatch(fetchRules()), 3000);
   }