]> cat aescling's git repositories - mastodon.git/commitdiff
Add frontend support for inline spoiler text
authorkibigo! <go@kibi.family>
Fri, 11 Nov 2022 09:39:42 +0000 (01:39 -0800)
committerkibigo! <go@kibi.family>
Sun, 13 Nov 2022 04:50:44 +0000 (20:50 -0800)
To get around the fact that React manages the contents of statuses
through HTML injection, this commit adds a Redux action/reducer for
dynamically mutating the content of a status in the store. This
approach has some flaws:

- It’s messy as fuck, and

- If the status is reloaded for whatever reason (i·e it is received
  again through the A·P·I), the contents won’t match and there will be
  *another* (unnecessary) status body update (which will close any
  open spoilers in the status).

Still, I think this is the best that can be done with Mastodon’s
current architecture.

12 files changed:
app/javascript/flavours/glitch/actions/importer/normalizer.js
app/javascript/flavours/glitch/actions/statuses.js
app/javascript/flavours/glitch/components/status.js
app/javascript/flavours/glitch/components/status_content.js
app/javascript/flavours/glitch/containers/status_container.js
app/javascript/flavours/glitch/features/status/components/detailed_status.js
app/javascript/flavours/glitch/features/status/index.js
app/javascript/flavours/glitch/locales/en.js
app/javascript/flavours/glitch/reducers/statuses.js
app/javascript/flavours/glitch/styles/components/status.scss
app/javascript/flavours/glitch/styles/variables.scss
app/javascript/flavours/glitch/utils/spoilertextify.js [new file with mode: 0644]

index 9950a720bd37859e793928e68ec9528a06945bd4..7bf51d72a6f329d609aada906ca4a0cab56063a9 100644 (file)
@@ -2,6 +2,7 @@ import escapeTextContentForBrowser from 'escape-html';
 import emojify from 'flavours/glitch/util/emoji';
 import { unescapeHTML } from 'flavours/glitch/util/html';
 import { autoHideCW } from 'flavours/glitch/util/content_warning';
+import spoilertextify from 'flavours/glitch/utils/spoilertextify';
 
 const domParser = new DOMParser();
 
@@ -79,8 +80,23 @@ export function normalizeStatus(status, normalOldStatus, settings) {
     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');
     const emojiMap      = makeEmojiMap(normalStatus);
 
+    const contentHtml = emojify(normalStatus.content, emojiMap);
+    const statusDoc = domParser.parseFromString(
+      `<!DOCTYPE html><html>${contentHtml}`,
+      'text/html',
+    );
+    statusDoc.body.querySelectorAll(
+      'span[property="tag:ns.1024.gdn,2022-11-11:spoiler_text"]',
+    ).forEach((spoilerNode) => {
+      // Set up initial (hidden) spoilers.
+      spoilerNode.replaceWith(spoilertextify(
+        spoilerNode.getAttribute('content'),
+        { document: statusDoc },
+      ));
+    });
+
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
-    normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
+    normalStatus.contentHtml  = statusDoc.body.innerHTML;
     normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
     normalStatus.hidden       = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
   }
index 58c1d44a69170ea17be60fac333ea25107f19031..0624378db32bb0f6260e23658a4770d82d6d4a3f 100644 (file)
@@ -30,6 +30,8 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
 
 export const REDRAFT = 'REDRAFT';
 
+export const STATUS_MODIFY_BODY = 'STATUS_MODIFY_BODY';
+
 export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
 export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
 export const STATUS_FETCH_SOURCE_FAIL    = 'STATUS_FETCH_SOURCE_FAIL';
@@ -311,3 +313,11 @@ export function toggleStatusCollapse(id, isCollapsed) {
     isCollapsed,
   };
 }
+
+export function modifyStatusBody(id, newBody) {
+  return {
+    type: STATUS_MODIFY_BODY,
+    id,
+    newBody,
+  };
+}
index e238456c5bc4e9c843a4f9e899ccca9edd793c8c..1bb0d72a445c9f0352ce51b06420bdecdb12dcbc 100644 (file)
@@ -83,6 +83,7 @@ class Status extends ImmutablePureComponent {
     onEmbed: PropTypes.func,
     onHeightChange: PropTypes.func,
     onToggleHidden: PropTypes.func,
+    onToggleSpoilerText: PropTypes.func,
     muted: PropTypes.bool,
     hidden: PropTypes.bool,
     unread: PropTypes.bool,
@@ -499,6 +500,7 @@ class Status extends ImmutablePureComponent {
       intersectionObserverWrapper,
       onOpenVideo,
       onOpenMedia,
+      onToggleSpoilerText,
       notification,
       hidden,
       unread,
@@ -780,6 +782,7 @@ class Status extends ImmutablePureComponent {
             mediaIcons={contentMediaIcons}
             expanded={isExpanded}
             onExpandedToggle={this.handleExpandedToggle}
+            onToggleSpoilerText={onToggleSpoilerText}
             parseClick={parseClick}
             disabled={!router}
             tagLinks={settings.get('tag_misleading_links')}
index 39891da4f45aee619941486a7eba9bff718e5147..8aa03ad715b5046eacd0110ebba18a528630dcad 100644 (file)
@@ -1,7 +1,7 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
+import { injectIntl, FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
 import classnames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
@@ -62,6 +62,7 @@ const isLinkMisleading = (link) => {
   return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
 };
 
+@injectIntl
 export default class StatusContent extends React.PureComponent {
 
   static propTypes = {
@@ -69,6 +70,7 @@ export default class StatusContent extends React.PureComponent {
     expanded: PropTypes.bool,
     collapsed: PropTypes.bool,
     onExpandedToggle: PropTypes.func,
+    onToggleSpoilerText: PropTypes.func,
     media: PropTypes.node,
     extraMedia: PropTypes.node,
     mediaIcons: PropTypes.arrayOf(PropTypes.string),
@@ -77,6 +79,7 @@ export default class StatusContent extends React.PureComponent {
     onUpdate: PropTypes.func,
     tagLinks: PropTypes.bool,
     rewriteMentions: PropTypes.string,
+    intl: PropTypes.object.isRequired,
   };
 
   static defaultProps = {
@@ -96,6 +99,13 @@ export default class StatusContent extends React.PureComponent {
       return;
     }
 
+    [...node.querySelectorAll('.spoilertext')].forEach((elt) => {
+      elt.querySelector('button').addEventListener(
+        'click',
+        this.onSpoilerTextClick.bind(this, elt),
+      );
+    });
+
     const links = node.querySelectorAll('a');
 
     for (var i = 0; i < links.length; ++i) {
@@ -182,6 +192,21 @@ export default class StatusContent extends React.PureComponent {
   }
 
   componentDidMount () {
+    const node = this.contentsNode;
+    if (node) {
+      // Replace spoiler texts with their internationalized versions.
+      [...node.querySelectorAll('.spoilertext')].forEach((elt) => {
+        this.props.onToggleSpoilerText(
+          this.props.status,
+          node,
+          elt,
+          this.props.intl,
+          elt.classList.contains('open'),
+        );
+      });
+    }
+    // The `.onToggleSpoilerText()` method actually replaces the
+    // `.spoilertext` elements, so we need to call this *after*.
     this._updateStatusLinks();
   }
 
@@ -210,6 +235,16 @@ export default class StatusContent extends React.PureComponent {
     }
   }
 
+  onSpoilerTextClick = (spoilerElement, e) => {
+    e.preventDefault();
+    this.props.onToggleSpoilerText(
+      this.props.status,
+      this.contentsNode,
+      spoilerElement,
+      this.props.intl,
+    );
+  }
+
   handleMouseDown = (e) => {
     this.startXY = [e.clientX, e.clientY];
   }
index 0ba2e712c2f3abdb38125564e1b07e65c3e6b8d7..7d22ee97c3a21632d2244d0ae12529277996306f 100644 (file)
@@ -23,7 +23,8 @@ import {
   deleteStatus,
   hideStatus,
   revealStatus,
-  editStatus
+  editStatus,
+  modifyStatusBody,
 } from 'flavours/glitch/actions/statuses';
 import {
   initAddFilter,
@@ -42,6 +43,7 @@ import { showAlertForError } from '../actions/alerts';
 import AccountContainer from 'flavours/glitch/containers/account_container';
 import Spoilers from '../components/spoilers';
 import Icon from 'flavours/glitch/components/icon';
+import spoilertextify from 'flavours/glitch/utils/spoilertextify';
 
 const messages = defineMessages({
   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -232,6 +234,22 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     }
   },
 
+  onToggleSpoilerText (status, oldBody, spoilerElement, intl, open) {
+    spoilerElement.replaceWith(spoilertextify(
+      spoilerElement.querySelector('.spoilertext--content').textContent,
+      {
+        intl,
+        open: open == null
+          ? !spoilerElement.classList.contains('open')
+          : !!open,
+      },
+    ));
+    dispatch(modifyStatusBody(
+      status.get('id'),
+      oldBody.innerHTML,
+    ));
+  },
+
   deployPictureInPicture (status, type, mediaProps) {
     dispatch((_, getState) => {
       if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
index 91dc5ba208d8115aa1f5cf48193d174f2f02b5c5..9bd4e0f245a2608ca309f3ed75fb5a7fdcaf3d47 100644 (file)
@@ -20,6 +20,7 @@ import Icon from 'flavours/glitch/components/icon';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
 import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
+import spoilertextify from 'flavours/glitch/utils/spoilertextify';
 
 export default @injectIntl
 class DetailedStatus extends ImmutablePureComponent {
@@ -34,6 +35,7 @@ class DetailedStatus extends ImmutablePureComponent {
     onOpenMedia: PropTypes.func.isRequired,
     onOpenVideo: PropTypes.func.isRequired,
     onToggleHidden: PropTypes.func,
+    onToggleSpoilerText: PropTypes.func,
     expanded: PropTypes.bool,
     measureHeight: PropTypes.bool,
     onHeightChange: PropTypes.func,
@@ -114,7 +116,7 @@ class DetailedStatus extends ImmutablePureComponent {
 
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
-    const { expanded, onToggleHidden, settings, usingPiP, intl } = this.props;
+    const { expanded, onToggleHidden, onToggleSpoilerText, settings, usingPiP, intl } = this.props;
     const outerStyle = { boxSizing: 'border-box' };
     const { compact } = this.props;
 
@@ -305,6 +307,7 @@ class DetailedStatus extends ImmutablePureComponent {
             expanded={expanded}
             collapsed={false}
             onExpandedToggle={onToggleHidden}
+            onToggleSpoilerText={onToggleSpoilerText}
             parseClick={this.parseClick}
             onUpdate={this.handleChildUpdate}
             tagLinks={settings.get('tag_misleading_links')}
index 9c86d54db1fbd239f6947598d64d94beb0635781..29c82f121cc77ffea2b84360a28c41722750c03a 100644 (file)
@@ -32,7 +32,8 @@ import {
   deleteStatus,
   editStatus,
   hideStatus,
-  revealStatus
+  revealStatus,
+  modifyStatusBody,
 } from 'flavours/glitch/actions/statuses';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
@@ -52,6 +53,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
 import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
 import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status';
 import Icon from 'flavours/glitch/components/icon';
+import spoilertextify from 'flavours/glitch/utils/spoilertextify';
 
 const messages = defineMessages({
   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -236,6 +238,22 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleToggleSpoilerText = (status, oldBody, spoilerElement, intl, open) => {
+    spoilerElement.replaceWith(spoilertextify(
+      spoilerElement.querySelector('.spoilertext--content').textContent,
+      {
+        intl,
+        open: open == null
+          ? !spoilerElement.classList.contains('open')
+          : !!open,
+      },
+    ));
+    this.props.dispatch(modifyStatusBody(
+      status.get('id'),
+      oldBody.innerHTML,
+    ));
+  }
+
   handleToggleMediaVisibility = () => {
     this.setState({ showMedia: !this.state.showMedia });
   }
@@ -601,6 +619,7 @@ class Status extends ImmutablePureComponent {
                   settings={settings}
                   onOpenVideo={this.handleOpenVideo}
                   onOpenMedia={this.handleOpenMedia}
+                  onToggleSpoilerText={this.handleToggleSpoilerText}
                   expanded={isExpanded}
                   onToggleHidden={this.handleToggleHidden}
                   domain={domain}
index 90e924d4a613669d33a98fb73ed94ad2eaac26ce..65bee5fb91690fb238bf17ab668e70ac5e8b2f27 100644 (file)
@@ -34,6 +34,8 @@ const messages = {
   'settings.navbar_under': 'Navbar at the bottom (Mobile only)',
   'status.collapse': 'Collapse',
   'status.uncollapse': 'Uncollapse',
+  'status.spoilertext.hidden': '[currently hidden]',
+  'status.spoilertext.show': 'show spoiler',
 
   'media_gallery.sensitive': 'Sensitive',
 
index 333e4b45c59b0604e716566c8dd59f913981e77f..03f4e4c61eb2c21c7a2997b1b7dc99b6aadc156a 100644 (file)
@@ -13,6 +13,7 @@ import {
   STATUS_REVEAL,
   STATUS_HIDE,
   STATUS_COLLAPSE,
+  STATUS_MODIFY_BODY,
 } from 'flavours/glitch/actions/statuses';
 import {
   TIMELINE_DELETE,
@@ -77,6 +78,8 @@ export default function statuses(state = initialState, action) {
     });
   case STATUS_COLLAPSE:
     return state.setIn([action.id, 'collapsed'], action.isCollapsed);
+  case STATUS_MODIFY_BODY:
+    return state.setIn([action.id, 'contentHtml'], action.newBody);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   default:
index 9e346c5f08f15b0527ebfac95973e3f5d2d36b3f..b818ee408ac85c378ae70cdad5189a2e91678392 100644 (file)
         background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));
         pointer-events: none;
       }
-      
+
       a:hover {
         text-decoration: none;
       }
@@ -1163,3 +1163,50 @@ a.status-card.compact:hover {
     border-color: lighten($ui-base-color, 12%);
   }
 }
+
+.spoilertext {
+  .spoilertext--screenreader-only {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    padding: 0;
+    margin: -1px;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    border: 0;
+  }
+
+  .spoilertext--content {
+    margin: -1px 0;
+    padding: 0 .5ch;
+    box-decoration-break: clone;
+    color: $text-on-shadow-color;
+    background: rgba($base-shadow-color, .2);
+    border: 1px $dark-text-color dashed;
+  }
+
+  &:not(.open) .spoilertext--content {
+    border-color: transparent;
+    color: transparent;
+    background: $base-shadow-color;
+    user-select: none;
+    pointer-events: none;
+  }
+
+  .spoilertext--span { white-space: nowrap }
+
+  .spoilertext--button {
+    all: unset;
+    border-radius: 0 20% 20% 0;
+    padding: 0 .5ch;
+    color: $text-on-shadow-color;
+    background: rgba($base-shadow-color, .4);
+
+    .fa {
+      font-style: normal;
+      transition: rotate .5s;
+    }
+
+    &:focus-visible .fa { rotate: 90deg }
+  }
+}
index 65758e6e09403332d47fc235686cd54c3d3410b1..4c138805a2ccb19c8abaae47fe5b90668d227024 100644 (file)
@@ -38,6 +38,7 @@ $highlight-text-color: lighten($ui-highlight-color, 8%) !default;
 $action-button-color: $ui-base-lighter-color !default;
 $passive-text-color: $gold-star !default;
 $active-passive-text-color: $success-green !default;
+$text-on-shadow-color: $white !default;
 // For texts on inverted backgrounds
 $inverted-text-color: $ui-base-color !default;
 $lighter-text-color: $ui-base-lighter-color !default;
diff --git a/app/javascript/flavours/glitch/utils/spoilertextify.js b/app/javascript/flavours/glitch/utils/spoilertextify.js
new file mode 100644 (file)
index 0000000..7eac6e4
--- /dev/null
@@ -0,0 +1,55 @@
+import { defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  spoilerHidden: {
+    id: 'status.spoilertext.hidden',
+    defaultMessage: '[currently hidden]',
+  },
+  showSpoiler: {
+    id: `status.spoilertext.show`,
+    defaultMessage: `show spoiler`,
+  },
+});
+
+/**
+ * Generates a `<span>` node which represents an inline spoiler for the
+ * provided text.
+ */
+export default (text, options) => {
+  const doc = options?.document || document;
+  const { intl, open } = options;
+  const result = doc.createElement('span');
+  result.className = open ? 'spoilertext open' : 'spoilertext';
+  if (!open) {
+    const accessibleDescription = doc.createElement('span');
+    accessibleDescription.className = 'spoilertext--screenreader-only';
+    accessibleDescription.textContent = intl?.formatMessage?.(messages.spoilerHidden) || '';
+    result.append(accessibleDescription);
+  }
+  const textElt = doc.createElement('span');
+  textElt.className = 'spoilertext--content';
+  textElt.setAttribute('aria-hidden', open ? 'false' : 'true');
+  textElt.textContent = text;
+  const togglerSpan = doc.createElement('span');
+  togglerSpan.className = 'spoilertext--span';
+  const togglerButton = doc.createElement('button');
+  togglerButton.className = 'spoilertext--button';
+  const togglerMessage = intl?.formatMessage?.(messages.showSpoiler) || '';
+  togglerButton.setAttribute('type', 'button');
+  togglerButton.setAttribute('aria-label', togglerMessage);
+  togglerButton.setAttribute('aria-pressed', open ? 'true' : 'false');
+  togglerButton.setAttribute('title', togglerMessage);
+  const togglerIcon = doc.createElement('i');
+  togglerIcon.setAttribute('role', 'img');
+  togglerIcon.setAttribute(
+    'class',
+    `fa ${open ? 'fa-eye-slash' : 'fa-eye'}`,
+  );
+  togglerButton.append(togglerIcon);
+  togglerSpan.append(
+    '\u2060', // word joiner to prevent a linebreak
+    togglerButton,
+  );
+  result.append(textElt, togglerSpan);
+  return result;
+};