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();
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);
}
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';
isCollapsed,
};
}
+
+export function modifyStatusBody(id, newBody) {
+ return {
+ type: STATUS_MODIFY_BODY,
+ id,
+ newBody,
+ };
+}
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
+ onToggleSpoilerText: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
intersectionObserverWrapper,
onOpenVideo,
onOpenMedia,
+ onToggleSpoilerText,
notification,
hidden,
unread,
mediaIcons={contentMediaIcons}
expanded={isExpanded}
onExpandedToggle={this.handleExpandedToggle}
+ onToggleSpoilerText={onToggleSpoilerText}
parseClick={parseClick}
disabled={!router}
tagLinks={settings.get('tag_misleading_links')}
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';
return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
};
+@injectIntl
export default class StatusContent extends React.PureComponent {
static propTypes = {
expanded: PropTypes.bool,
collapsed: PropTypes.bool,
onExpandedToggle: PropTypes.func,
+ onToggleSpoilerText: PropTypes.func,
media: PropTypes.node,
extraMedia: PropTypes.node,
mediaIcons: PropTypes.arrayOf(PropTypes.string),
onUpdate: PropTypes.func,
tagLinks: PropTypes.bool,
rewriteMentions: PropTypes.string,
+ intl: PropTypes.object.isRequired,
};
static defaultProps = {
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) {
}
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();
}
}
}
+ 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];
}
deleteStatus,
hideStatus,
revealStatus,
- editStatus
+ editStatus,
+ modifyStatusBody,
} from 'flavours/glitch/actions/statuses';
import {
initAddFilter,
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' },
}
},
+ 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'])) {
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 {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func,
+ onToggleSpoilerText: PropTypes.func,
expanded: PropTypes.bool,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
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;
expanded={expanded}
collapsed={false}
onExpandedToggle={onToggleHidden}
+ onToggleSpoilerText={onToggleSpoilerText}
parseClick={this.parseClick}
onUpdate={this.handleChildUpdate}
tagLinks={settings.get('tag_misleading_links')}
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';
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' },
}
}
+ 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 });
}
settings={settings}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
+ onToggleSpoilerText={this.handleToggleSpoilerText}
expanded={isExpanded}
onToggleHidden={this.handleToggleHidden}
domain={domain}
'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',
STATUS_REVEAL,
STATUS_HIDE,
STATUS_COLLAPSE,
+ STATUS_MODIFY_BODY,
} from 'flavours/glitch/actions/statuses';
import {
TIMELINE_DELETE,
});
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:
background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));
pointer-events: none;
}
-
+
a:hover {
text-decoration: none;
}
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 }
+ }
+}
$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;
--- /dev/null
+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;
+};