Add option to share CW toggle state across instances of a post

main
Claire 2022-07-24 20:01:30 +02:00
parent eacde1a130
commit 18346f4044
12 changed files with 191 additions and 69 deletions

View File

@ -63,7 +63,7 @@ export function importFetchedStatuses(statuses) {
const polls = [];
function processStatus(status) {
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings')));
pushUnique(accounts, status.account);
if (status.reblog && status.reblog.id) {

View File

@ -1,6 +1,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';
const domParser = new DOMParser();
@ -41,7 +42,7 @@ export function normalizeAccount(account) {
return account;
}
export function normalizeStatus(status, normalOldStatus) {
export function normalizeStatus(status, normalOldStatus, settings) {
const normalStatus = { ...status };
normalStatus.account = status.account.id;
@ -60,6 +61,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.search_index = normalOldStatus.get('search_index');
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.hidden = normalOldStatus.get('hidden');
} else {
const spoilerText = normalStatus.spoiler_text || '';
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');
@ -68,6 +70,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
}
return normalStatus;

View File

@ -24,6 +24,10 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
export const STATUS_REVEAL = 'STATUS_REVEAL';
export const STATUS_HIDE = 'STATUS_HIDE';
export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
export const REDRAFT = 'REDRAFT';
export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
@ -277,3 +281,33 @@ export function unmuteStatusFail(id, error) {
error,
};
};
export function hideStatus(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
return {
type: STATUS_HIDE,
ids,
};
};
export function revealStatus(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
return {
type: STATUS_REVEAL,
ids,
};
};
export function toggleStatusCollapse(id, isCollapsed) {
return {
type: STATUS_COLLAPSE,
id,
isCollapsed,
};
}

View File

@ -81,8 +81,8 @@ class Status extends ImmutablePureComponent {
onBlock: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
muted: PropTypes.bool,
collapse: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
prepend: PropTypes.string,
@ -121,7 +121,6 @@ class Status extends ImmutablePureComponent {
'settings',
'prepend',
'muted',
'collapse',
'notification',
'hidden',
'expanded',
@ -149,14 +148,14 @@ class Status extends ImmutablePureComponent {
let updated = false;
// Make sure the state mirrors props we track…
if (nextProps.collapse !== prevState.collapseProp) {
update.collapseProp = nextProps.collapse;
updated = true;
}
if (nextProps.expanded !== prevState.expandedProp) {
update.expandedProp = nextProps.expanded;
updated = true;
}
if (nextProps.status?.get('hidden') !== prevState.statusPropHidden) {
update.statusPropHidden = nextProps.status?.get('hidden');
updated = true;
}
// Update state based on new props
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
@ -164,14 +163,19 @@ class Status extends ImmutablePureComponent {
update.isCollapsed = false;
updated = true;
}
} else if (
nextProps.collapse !== prevState.collapseProp &&
nextProps.collapse !== undefined
}
// Handle uncollapsing toots when the shared CW state is expanded
if (nextProps.settings.getIn(['content_warnings', 'shared_state']) &&
nextProps.status?.get('spoiler_text')?.length && nextProps.status?.get('hidden') === false &&
prevState.statusPropHidden !== false && prevState.isCollapsed
) {
update.isCollapsed = nextProps.collapse;
if (nextProps.collapse) update.isExpanded = false;
update.isCollapsed = false;
updated = true;
}
// The “expanded” prop is used to one-off change the local state.
// It's used in the thread view when unfolding/re-folding all CWs at once.
if (nextProps.expanded !== prevState.expandedProp &&
nextProps.expanded !== undefined
) {
@ -180,15 +184,9 @@ class Status extends ImmutablePureComponent {
updated = true;
}
if (nextProps.expanded === undefined &&
prevState.isExpanded === undefined &&
update.isExpanded === undefined
) {
const isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
if (isExpanded !== undefined) {
update.isExpanded = isExpanded;
updated = true;
}
if (prevState.isExpanded === undefined && update.isExpanded === undefined) {
update.isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
updated = true;
}
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
@ -243,22 +241,18 @@ class Status extends ImmutablePureComponent {
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
if (function () {
switch (true) {
case !!collapse:
case !!autoCollapseSettings.get('all'):
case autoCollapseSettings.get('notifications') && !!muted:
case autoCollapseSettings.get('lengthy') && node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
):
case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size:
return true;
default:
return false;
}
}()) {
// Don't autocollapse if CW state is shared and status is explicitly revealed,
// as it could cause surprising changes when receiving notifications
if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return;
if (collapse ||
autoCollapseSettings.get('all') ||
(autoCollapseSettings.get('notifications') && muted) ||
(autoCollapseSettings.get('lengthy') && node.clientHeight > ((status.get('media_attachments').size && !muted) ? 650 : 400)) ||
(autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') ||
(autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) ||
(autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0)
) {
this.setCollapsed(true);
// Hack to fix timeline jumps on second rendering when auto-collapsing
this.setState({ autoCollapsed: true });
@ -309,16 +303,20 @@ class Status extends ImmutablePureComponent {
// is enabled, so we don't have to.
setCollapsed = (value) => {
if (this.props.settings.getIn(['collapsed', 'enabled'])) {
this.setState({ isCollapsed: value });
if (value) {
this.setExpansion(false);
}
this.setState({ isCollapsed: value });
} else {
this.setState({ isCollapsed: false });
}
}
setExpansion = (value) => {
if (this.props.settings.getIn(['content_warnings', 'shared_state']) && this.props.status.get('hidden') === value) {
this.props.onToggleHidden(this.props.status);
}
this.setState({ isExpanded: value });
if (value) {
this.setCollapsed(false);
@ -365,7 +363,9 @@ class Status extends ImmutablePureComponent {
}
handleExpandedToggle = () => {
if (this.props.status.get('spoiler_text')) {
if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
this.props.onToggleHidden(this.props.status);
} else if (this.props.status.get('spoiler_text')) {
this.setExpansion(!this.state.isExpanded);
}
};
@ -505,7 +505,7 @@ class Status extends ImmutablePureComponent {
usingPiP,
...other
} = this.props;
const { isExpanded, isCollapsed, forceFilter } = this.state;
const { isCollapsed, forceFilter } = this.state;
let background = null;
let attachments = null;
@ -528,6 +528,8 @@ class Status extends ImmutablePureComponent {
return null;
}
const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
const handlers = {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,

View File

@ -17,7 +17,14 @@ import {
pin,
unpin,
} from 'flavours/glitch/actions/interactions';
import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses';
import {
muteStatus,
unmuteStatus,
deleteStatus,
hideStatus,
revealStatus,
editStatus
} from 'flavours/glitch/actions/statuses';
import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports';
@ -252,6 +259,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
deployPictureInPicture (status, type, mediaProps) {
dispatch((_, getState) => {
if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {

View File

@ -132,6 +132,8 @@ class Conversation extends ImmutablePureComponent {
}
handleShowMore = () => {
this.props.onToggleHidden(this.props.lastStatus);
if (this.props.lastStatus.get('spoiler_text')) {
this.setExpansion(!this.state.isExpanded);
}
@ -143,12 +145,13 @@ class Conversation extends ImmutablePureComponent {
render () {
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
const { isExpanded } = this.state;
if (lastStatus === null) {
return null;
}
const isExpanded = this.props.settings.getIn(['content_warnings', 'shared_state']) ? !lastStatus.get('hidden') : this.state.isExpanded;
const menu = [
{ text: intl.formatMessage(messages.open), action: this.handleClick },
null,

View File

@ -23,6 +23,7 @@ const mapStateToProps = () => {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
settings: state.get('local_settings'),
};
};
};

View File

@ -303,6 +303,15 @@ class LocalSettingsPage extends React.PureComponent {
({ intl, onChange, settings }) => (
<div className='glitch local-settings__page content_warnings'>
<h1><FormattedMessage id='settings.content_warnings' defaultMessage='Content warnings' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['content_warnings', 'shared_state']}
id='mastodon-settings--content_warnings-shared_state'
onChange={onChange}
>
<FormattedMessage id='settings.content_warnings_shared_state' defaultMessage='Show/hide content of all copies at once' />
<span className='hint'><FormattedMessage id='settings.content_warnings_shared_state_hint' defaultMessage='Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW' /></span>
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['content_warnings', 'media_outside']}

View File

@ -26,7 +26,14 @@ import {
directCompose,
} from 'flavours/glitch/actions/compose';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses';
import {
muteStatus,
unmuteStatus,
deleteStatus,
editStatus,
hideStatus,
revealStatus
} from 'flavours/glitch/actions/statuses';
import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports';
@ -215,11 +222,19 @@ class Status extends ImmutablePureComponent {
return updated ? update : null;
}
handleExpandedToggle = () => {
if (this.props.status.get('spoiler_text')) {
handleToggleHidden = () => {
const { status } = this.props;
if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
if (status.get('hidden')) {
this.props.dispatch(revealStatus(status.get('id')));
} else {
this.props.dispatch(hideStatus(status.get('id')));
}
} else if (this.props.status.get('spoiler_text')) {
this.setExpansion(!this.state.isExpanded);
}
};
}
handleToggleMediaVisibility = () => {
this.setState({ showMedia: !this.state.showMedia });
@ -354,7 +369,19 @@ class Status extends ImmutablePureComponent {
}
handleToggleAll = () => {
const { isExpanded } = this.state;
const { status, ancestorsIds, descendantsIds, settings } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
let { isExpanded } = this.state;
if (settings.getIn(['content_warnings', 'shared_state']))
isExpanded = !status.get('hidden');
if (!isExpanded) {
this.props.dispatch(revealStatus(statusIds));
} else {
this.props.dispatch(hideStatus(statusIds));
}
this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded });
}
@ -513,9 +540,8 @@ class Status extends ImmutablePureComponent {
render () {
let ancestors, descendants;
const { setExpansion } = this;
const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
const { fullscreen, isExpanded } = this.state;
const { fullscreen } = this.state;
if (status === null) {
return (
@ -526,6 +552,8 @@ class Status extends ImmutablePureComponent {
);
}
const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
if (ancestorsIds && ancestorsIds.size > 0) {
ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
}
@ -543,7 +571,7 @@ class Status extends ImmutablePureComponent {
bookmark: this.handleHotkeyBookmark,
mention: this.handleHotkeyMention,
openProfile: this.handleHotkeyOpenProfile,
toggleSpoiler: this.handleExpandedToggle,
toggleSpoiler: this.handleToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
};
@ -574,7 +602,7 @@ class Status extends ImmutablePureComponent {
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
expanded={isExpanded}
onToggleHidden={this.handleExpandedToggle}
onToggleHidden={this.handleToggleHidden}
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}

View File

@ -27,6 +27,7 @@ const initialState = ImmutableMap({
content_warnings : ImmutableMap({
filter : null,
media_outside: false,
shared_state : false,
}),
collapsed : ImmutableMap({
enabled : true,

View File

@ -10,6 +10,9 @@ import {
import {
STATUS_MUTE_SUCCESS,
STATUS_UNMUTE_SUCCESS,
STATUS_REVEAL,
STATUS_HIDE,
STATUS_COLLAPSE,
} from 'flavours/glitch/actions/statuses';
import {
TIMELINE_DELETE,
@ -56,6 +59,24 @@ export default function statuses(state = initialState, action) {
return state.setIn([action.id, 'muted'], true);
case STATUS_UNMUTE_SUCCESS:
return state.setIn([action.id, 'muted'], false);
case STATUS_REVEAL:
return state.withMutations(map => {
action.ids.forEach(id => {
if (!(state.get(id) === undefined)) {
map.setIn([id, 'hidden'], false);
}
});
});
case STATUS_HIDE:
return state.withMutations(map => {
action.ids.forEach(id => {
if (!(state.get(id) === undefined)) {
map.setIn([id, 'hidden'], true);
}
});
});
case STATUS_COLLAPSE:
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
default:

View File

@ -1,26 +1,31 @@
import { expandSpoilers } from 'flavours/glitch/util/initial_state';
export function autoUnfoldCW (settings, status) {
if (!expandSpoilers) {
function _autoUnfoldCW(spoiler_text, skip_unfold_regex) {
if (!expandSpoilers)
return false;
}
const rawRegex = settings.getIn(['content_warnings', 'filter']);
if (!skip_unfold_regex)
return true;
if (!rawRegex) {
let regex = null;
try {
regex = new RegExp(skip_unfold_regex.trim(), 'i');
} catch (e) {
// Bad regex, skip filters
return true;
}
let regex = null;
try {
regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
} catch (e) {
// Bad regex, don't affect filters
}
if (!(status && regex)) {
return undefined;
}
return !regex.test(status.get('spoiler_text'));
return !regex.test(spoiler_text);
}
export function autoHideCW(settings, spoiler_text) {
return !_autoUnfoldCW(spoiler_text, settings.getIn(['content_warnings', 'filter']));
}
export function autoUnfoldCW(settings, status) {
if (!status)
return false;
return _autoUnfoldCW(status.get('spoiler_text'), settings.getIn(['content_warnings', 'filter']));
}