From eac953a2c8b0dd19a8f913b1ff02d62b9194ba73 Mon Sep 17 00:00:00 2001 From: kibigo! Date: Fri, 11 Nov 2022 01:39:42 -0800 Subject: [PATCH] Add frontend support for inline spoiler text MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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. --- .../glitch/actions/importer/normalizer.js | 18 +++++- .../flavours/glitch/actions/statuses.js | 10 ++++ .../flavours/glitch/components/status.js | 3 + .../glitch/components/status_content.js | 37 ++++++++++++- .../glitch/containers/status_container.js | 20 ++++++- .../status/components/detailed_status.js | 5 +- .../flavours/glitch/features/status/index.js | 21 ++++++- app/javascript/flavours/glitch/locales/en.js | 2 + .../flavours/glitch/reducers/statuses.js | 3 + .../glitch/styles/components/status.scss | 49 ++++++++++++++++- .../flavours/glitch/styles/variables.scss | 1 + .../flavours/glitch/utils/spoilertextify.js | 55 +++++++++++++++++++ 12 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 app/javascript/flavours/glitch/utils/spoilertextify.js diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 9950a720b..7bf51d72a 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -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(//g, '\n').replace(/<\/p>

/g, '\n\n'); const emojiMap = makeEmojiMap(normalStatus); + const contentHtml = emojify(normalStatus.content, emojiMap); + const statusDoc = domParser.parseFromString( + `${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); } diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 58c1d44a6..0624378db 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -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, + }; +} diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index e238456c5..1bb0d72a4 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -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')} diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index 39891da4f..8aa03ad71 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -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]; } diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 0ba2e712c..7d22ee97c 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -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'])) { 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 91dc5ba20..9bd4e0f24 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -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')} diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 9c86d54db..29c82f121 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -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} diff --git a/app/javascript/flavours/glitch/locales/en.js b/app/javascript/flavours/glitch/locales/en.js index 90e924d4a..65bee5fb9 100644 --- a/app/javascript/flavours/glitch/locales/en.js +++ b/app/javascript/flavours/glitch/locales/en.js @@ -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', diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index 333e4b45c..03f4e4c61 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -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: diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 9e346c5f0..b818ee408 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -408,7 +408,7 @@ 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 } + } +} diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss index 65758e6e0..4c138805a 100644 --- a/app/javascript/flavours/glitch/styles/variables.scss +++ b/app/javascript/flavours/glitch/styles/variables.scss @@ -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 index 000000000..7eac6e4b0 --- /dev/null +++ b/app/javascript/flavours/glitch/utils/spoilertextify.js @@ -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 `` 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; +}; -- 2.47.3