about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js2
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js2
-rw-r--r--app/javascript/flavours/glitch/components/status.js1
-rw-r--r--app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.js85
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js11
-rw-r--r--app/javascript/flavours/glitch/styles/accessibility.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss26
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/variables.scss2
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js11
11 files changed, 134 insertions, 22 deletions
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 52ad17779..05955963c 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
 
 export function searchTextFromRawStatus (status) {
   const spoilerText   = status.spoiler_text || '';
-  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
   return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
 }
 
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
index ec2fbbe4b..1ce2f42b4 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
@@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
             <span style={{ display: 'none' }}>{placeholder}</span>
 
             <Textarea
-              inputRef={this.setTextarea}
+              ref={this.setTextarea}
               className='autosuggest-textarea__textarea'
               disabled={disabled}
               placeholder={placeholder}
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 91bc06b3c..e036c0da7 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -656,6 +656,7 @@ class Status extends ImmutablePureComponent {
           compact
           cacheWidth={this.props.cacheMediaWidth}
           defaultWidth={this.props.cachedMediaWidth}
+          sensitive={status.get('sensitive')}
         />
       );
       mediaIcon = 'link';
diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
index 0bb71e872..abc3f468f 100644
--- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
+++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
@@ -106,6 +106,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
                 <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td>
               </tr>
               <tr>
+                <td><kbd>alt</kbd>+<kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
+              </tr>
+              <tr>
                 <td><kbd>backspace</kbd></td>
                 <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
               </tr>
diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js
index e3ee7dada..03867e03a 100644
--- a/app/javascript/flavours/glitch/features/status/components/card.js
+++ b/app/javascript/flavours/glitch/features/status/components/card.js
@@ -2,10 +2,14 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import Immutable from 'immutable';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
 import punycode from 'punycode';
 import classnames from 'classnames';
 import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
 import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { useBlurhash } from 'flavours/glitch/util/initial_state';
+import { decode } from 'blurhash';
 
 const getHostname = url => {
   const parser = document.createElement('a');
@@ -55,6 +59,7 @@ export default class Card extends React.PureComponent {
     compact: PropTypes.bool,
     defaultWidth: PropTypes.number,
     cacheWidth: PropTypes.func,
+    sensitive: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -64,12 +69,44 @@ export default class Card extends React.PureComponent {
 
   state = {
     width: this.props.defaultWidth || 280,
+    previewLoaded: false,
     embedded: false,
+    revealed: !this.props.sensitive,
   };
 
   componentWillReceiveProps (nextProps) {
     if (!Immutable.is(this.props.card, nextProps.card)) {
-      this.setState({ embedded: false });
+      this.setState({ embedded: false, previewLoaded: false });
+    }
+    if (this.props.sensitive !== nextProps.sensitive) {
+      this.setState({ revealed: !nextProps.sensitive });
+    }
+  }
+
+  componentDidMount () {
+    if (this.props.card && this.props.card.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { card } = this.props;
+    if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    if (!useBlurhash) return;
+
+    const hash   = this.props.card.get('blurhash');
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
     }
   }
 
@@ -111,6 +148,18 @@ export default class Card extends React.PureComponent {
     }
   }
 
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  handleImageLoad = () => {
+    this.setState({ previewLoaded: true });
+  }
+
+  handleReveal = () => {
+    this.setState({ revealed: true });
+  }
+
   renderVideo () {
     const { card }  = this.props;
     const content   = { __html: addAutoPlay(card.get('html')) };
@@ -130,7 +179,7 @@ export default class Card extends React.PureComponent {
 
   render () {
     const { card, maxDescription, compact, defaultWidth } = this.props;
-    const { width, embedded } = this.state;
+    const { width, embedded, revealed } = this.state;
 
     if (card === null) {
       return null;
@@ -145,7 +194,7 @@ export default class Card extends React.PureComponent {
     const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
-      <div className='status-card__content'>
+      <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
         {title}
         {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
@@ -153,7 +202,18 @@ export default class Card extends React.PureComponent {
     );
 
     let embed     = '';
-    let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
+    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
+    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let spoilerButton = (
+      <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
+        <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+      </button>
+    );
+    spoilerButton = (
+      <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+        {spoilerButton}
+      </div>
+    );
 
     if (interactive) {
       if (embedded) {
@@ -167,14 +227,18 @@ export default class Card extends React.PureComponent {
 
         embed = (
           <div className='status-card__image'>
+            {canvas}
             {thumbnail}
 
-            <div className='status-card__actions'>
-              <div>
-                <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
-                {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+            {revealed && (
+              <div className='status-card__actions'>
+                <div>
+                  <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
+                  {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+                </div>
               </div>
-            </div>
+            )}
+            {!revealed && spoilerButton}
           </div>
         );
       }
@@ -188,13 +252,16 @@ export default class Card extends React.PureComponent {
     } else if (card.get('image')) {
       embed = (
         <div className='status-card__image'>
+          {canvas}
           {thumbnail}
+          {!revealed && spoilerButton}
         </div>
       );
     } else {
       embed = (
         <div className='status-card__image'>
           <Icon id='file-text' />
+          {!revealed && spoilerButton}
         </div>
       );
     }
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 17f22a8a2..4fbd65517 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -184,7 +184,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
         mediaIcon = 'picture-o';
       }
     } else if (status.get('card')) {
-      media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
+      media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
       mediaIcon = 'link';
     }
 
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index f8f6cff88..bf76c0e57 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -7,7 +7,7 @@ import { connect } from 'react-redux';
 import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from 'flavours/glitch/util/is_mobile';
 import { debounce } from 'lodash';
-import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose';
+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';
@@ -81,6 +81,7 @@ const keyMap = {
   new: 'n',
   search: 's',
   forceNew: 'option+n',
+  toggleComposeSpoilers: 'option+x',
   focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
   reply: 'r',
   favourite: 'f',
@@ -396,7 +397,7 @@ class UI extends React.Component {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
     };
   }
 
@@ -455,6 +456,11 @@ class UI extends React.Component {
     this.props.dispatch(resetCompose());
   }
 
+  handleHotkeyToggleComposeSpoilers = e => {
+    e.preventDefault();
+    this.props.dispatch(changeComposeSpoilerness());
+  }
+
   handleHotkeyFocusColumn = e => {
     const index  = (e.key * 1) + 1; // First child is drawer, skip that
     const column = this.node.querySelector(`.column:nth-child(${index})`);
@@ -569,6 +575,7 @@ class UI extends React.Component {
       new: this.handleHotkeyNew,
       search: this.handleHotkeySearch,
       forceNew: this.handleHotkeyForceNew,
+      toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
       focusColumn: this.handleHotkeyFocusColumn,
       back: this.handleHotkeyBack,
       goToHome: this.handleHotkeyGoToHome,
diff --git a/app/javascript/flavours/glitch/styles/accessibility.scss b/app/javascript/flavours/glitch/styles/accessibility.scss
index 35e91da80..1a2de2f06 100644
--- a/app/javascript/flavours/glitch/styles/accessibility.scss
+++ b/app/javascript/flavours/glitch/styles/accessibility.scss
@@ -1,13 +1,13 @@
-$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;
+$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default;
 
-%emoji-outline {
-  filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);
+%emoji-color-inversion {
+  filter: invert(1);
 }
 
 .emojione {
-  @each $emoji in $emojis-requiring-outlines {
+  @each $emoji in $emojis-requiring-inversion {
     &[title=':#{$emoji}:'] {
-      @extend %emoji-outline;
+      @extend %emoji-color-inversion;
     }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 50b7f2a72..28a4ce0ce 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -874,6 +874,11 @@ a.status-card {
   flex: 1 1 auto;
   overflow: hidden;
   padding: 14px 14px 14px 8px;
+
+  &--blurred {
+    filter: blur(2px);
+    pointer-events: none;
+  }
 }
 
 .status-card__description {
@@ -911,7 +916,8 @@ a.status-card {
     width: 100%;
   }
 
-  .status-card__image-image {
+  .status-card__image-image,
+  .status-card__image-preview {
     border-radius: 4px 4px 0 0;
   }
 
@@ -956,6 +962,24 @@ a.status-card.compact:hover {
   background-position: center center;
 }
 
+.status-card__image-preview {
+  border-radius: 4px 0 0 4px;
+  display: block;
+  margin: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: fill;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 0;
+  background: $base-overlay-background;
+
+  &--hidden {
+    display: none;
+  }
+}
+
 .attachment-list {
   display: flex;
   font-size: 14px;
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
index 312f5e314..7709d4535 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
@@ -37,4 +37,4 @@ $account-background-color: $white !default;
   @return hsl(hue($color), saturation($color), lightness($color) - $amount);
 }
 
-$emojis-requiring-outlines: 'alien' 'baseball' 'chains' 'chicken' 'cloud' 'crescent_moon' 'dash' 'dove_of_peace' 'eyes' 'first_quarter_moon' 'first_quarter_moon_with_face' 'fish_cake' 'full_moon' 'full_moon_with_face' 'ghost' 'goat' 'grey_exclamation' 'grey_question' 'ice_skate' 'last_quarter_moon' 'last_quarter_moon_with_face' 'lightning' 'loud_sound' 'moon' 'mute' 'page_with_curl' 'rain_cloud' 'ram' 'rice' 'rice_ball' 'rooster' 'sheep' 'skull' 'skull_and_crossbones' 'snow_cloud' 'sound' 'speaker' 'speech_balloon' 'thought_balloon' 'volleyball' 'waning_crescent_moon' 'waning_gibbous_moon' 'waving_white_flag' 'waxing_crescent_moon' 'white_circle' 'white_large_square' 'white_medium_small_square' 'white_medium_square' 'white_small_square' 'wind_blowing_face';
+$emojis-requiring-inversion: 'chains';
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index e1a244127..22b657b05 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -6,6 +6,15 @@ const trie = new Trie(Object.keys(unicodeMapping));
 
 const assetHost = process.env.CDN_HOST || '';
 
+// Emoji requiring extra borders depending on theme
+const darkEmoji = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂‍♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂‍♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴';
+const lightEmoji = '👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️';
+
+const emojiFilename = (filename, match) => {
+  const borderedEmoji = (document.body && document.body.classList.contains('skin-mastodon-light')) ? lightEmoji : darkEmoji;
+  return borderedEmoji.includes(match) ? (filename + '_border') : filename;
+};
+
 const emojify = (str, customEmojis = {}) => {
   const tagCharsWithoutEmojis = '<&';
   const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
@@ -60,7 +69,7 @@ const emojify = (str, customEmojis = {}) => {
     } else if (!useSystemEmojiFont) { // matched to unicode emoji
       const { filename, shortCode } = unicodeMapping[match];
       const title = shortCode ? `:${shortCode}:` : '';
-      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
+      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename, match)}.svg" />`;
       rend = i + match.length;
       // If the matched character was followed by VS15 (for selecting text presentation), skip it.
       if (str.codePointAt(rend) === 65038) {