diff --git a/src/actions/settings.ts b/src/actions/settings.ts index 7bda7c00c3..a3025ee1d5 100644 --- a/src/actions/settings.ts +++ b/src/actions/settings.ts @@ -30,6 +30,7 @@ const defaultSettings = ImmutableMap({ underlineLinks: false, autoPlayGif: true, displayMedia: 'default', + displaySpoilers: false, unfollowModal: true, boostModal: false, deleteModal: true, diff --git a/src/actions/statuses.ts b/src/actions/statuses.ts index 2be5080116..75330d60ba 100644 --- a/src/actions/statuses.ts +++ b/src/actions/statuses.ts @@ -43,8 +43,11 @@ const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST' as const; const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS' as const; const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL' as const; -const STATUS_REVEAL = 'STATUS_REVEAL' as const; -const STATUS_HIDE = 'STATUS_HIDE' as const; +const STATUS_REVEAL_MEDIA = 'STATUS_REVEAL_MEDIA' as const; +const STATUS_HIDE_MEDIA = 'STATUS_HIDE_MEDIA' as const; + +const STATUS_EXPAND_SPOILER = 'STATUS_EXPAND_SPOILER' as const; +const STATUS_COLLAPSE_SPOILER = 'STATUS_COLLAPSE_SPOILER' as const; const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST' as const; const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS' as const; @@ -219,33 +222,63 @@ const toggleMuteStatus = (status: Pick) => } }; -const hideStatus = (statusIds: string[] | string) => { +const hideStatusMedia = (statusIds: string[] | string) => { if (!Array.isArray(statusIds)) { statusIds = [statusIds]; } return { - type: STATUS_HIDE, + type: STATUS_HIDE_MEDIA, statusIds, }; }; -const revealStatus = (statusIds: string[] | string) => { +const revealStatusMedia = (statusIds: string[] | string) => { if (!Array.isArray(statusIds)) { statusIds = [statusIds]; } return { - type: STATUS_REVEAL, + type: STATUS_REVEAL_MEDIA, statusIds, }; }; -const toggleStatusHidden = (status: Pick) => { +const toggleStatusMediaHidden = (status: Pick) => { if (status.hidden) { - return revealStatus(status.id); + return revealStatusMedia(status.id); } else { - return hideStatus(status.id); + return hideStatusMedia(status.id); + } +}; + +const collapseStatusSpoiler = (statusIds: string[] | string) => { + if (!Array.isArray(statusIds)) { + statusIds = [statusIds]; + } + + return { + type: STATUS_COLLAPSE_SPOILER, + statusIds, + }; +}; + +const expandStatusSpoiler = (statusIds: string[] | string) => { + if (!Array.isArray(statusIds)) { + statusIds = [statusIds]; + } + + return { + type: STATUS_EXPAND_SPOILER, + statusIds, + }; +}; + +const toggleStatusSpoilerExpanded = (status: Pick) => { + if (status.expanded) { + return collapseStatusSpoiler(status.id); + } else { + return expandStatusSpoiler(status.id); } }; @@ -359,8 +392,10 @@ export { STATUS_UNMUTE_REQUEST, STATUS_UNMUTE_SUCCESS, STATUS_UNMUTE_FAIL, - STATUS_REVEAL, - STATUS_HIDE, + STATUS_REVEAL_MEDIA, + STATUS_HIDE_MEDIA, + STATUS_EXPAND_SPOILER, + STATUS_COLLAPSE_SPOILER, STATUS_TRANSLATE_REQUEST, STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_FAIL, @@ -377,9 +412,12 @@ export { muteStatus, unmuteStatus, toggleMuteStatus, - hideStatus, - revealStatus, - toggleStatusHidden, + hideStatusMedia, + revealStatusMedia, + toggleStatusMediaHidden, + expandStatusSpoiler, + collapseStatusSpoiler, + toggleStatusSpoilerExpanded, translateStatus, undoStatusTranslation, unfilterStatus, diff --git a/src/components/status-content.tsx b/src/components/status-content.tsx index 15c2429421..cb37cfb3b3 100644 --- a/src/components/status-content.tsx +++ b/src/components/status-content.tsx @@ -1,11 +1,13 @@ import clsx from 'clsx'; import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode } from 'html-react-parser'; import React, { useState, useRef, useLayoutEffect, useMemo } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; +import { toggleStatusSpoilerExpanded } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; -import { Text } from 'soapbox/components/ui'; +import { Button, Text } from 'soapbox/components/ui'; +import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content'; import { getTextDirection } from '../utils/rtl'; @@ -18,19 +20,27 @@ import Poll from './polls/poll'; import type { Sizes } from 'soapbox/components/ui/text/text'; import type { MinifiedStatus } from 'soapbox/reducers/statuses'; -const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) +const MAX_HEIGHT = 322; // 20px * 16 (+ 2px padding at the top) const BIG_EMOJI_LIMIT = 10; +const messages = defineMessages({ + collapse: { id: 'status.spoiler.collapse', defaultMessage: 'Collapse' }, + expand: { id: 'status.spoiler.expand', defaultMessage: 'Expand' }, +}); + interface IReadMoreButton { onClick: React.MouseEventHandler; } /** Button to expand a truncated status (due to too much content) */ const ReadMoreButton: React.FC = ({ onClick }) => ( - +
+
+ +
); interface IStatusContent { @@ -49,6 +59,10 @@ const StatusContent: React.FC = React.memo(({ translatable, textSize = 'md', }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { displaySpoilers } = useSettings(); + const [collapsed, setCollapsed] = useState(false); const [onlyEmoji, setOnlyEmoji] = useState(false); @@ -73,6 +87,13 @@ const StatusContent: React.FC = React.memo(({ } }; + const toggleExpanded: React.MouseEventHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + + dispatch(toggleStatusSpoilerExpanded(status)); + }; + useLayoutEffect(() => { maybeSetCollapsed(); maybeSetOnlyEmoji(); @@ -152,7 +173,7 @@ const StatusContent: React.FC = React.memo(({ const className = clsx(baseClassName, { 'cursor-pointer': onClick, 'whitespace-normal': withSpoiler, - 'max-h-[300px]': collapsed, + 'max-h-[200px]': collapsed, 'leading-normal big-emoji': onlyEmoji, }); @@ -160,16 +181,33 @@ const StatusContent: React.FC = React.memo(({ ? status.spoilerMapHtml[status.currentLanguage] || status.spoilerHtml : status.spoilerHtml; + const expandable = !displaySpoilers; + const expanded = !withSpoiler || status.expanded || false; + const output = []; if (spoilerText) { output.push( + {expandable && ( + + )} , ); } + if (expandable && !expanded) return <>{output}; + if (onClick) { output.push( = (props) => { }; const handleHotkeyToggleSensitive = (): void => { - dispatch(toggleStatusHidden(actualStatus)); + dispatch(toggleStatusMediaHidden(actualStatus)); }; const handleHotkeyReact = (): void => { diff --git a/src/components/statuses/sensitive-content-overlay.tsx b/src/components/statuses/sensitive-content-overlay.tsx index f974cd8826..072bd18e5b 100644 --- a/src/components/statuses/sensitive-content-overlay.tsx +++ b/src/components/statuses/sensitive-content-overlay.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { toggleStatusHidden } from 'soapbox/actions/statuses'; +import { toggleStatusMediaHidden } from 'soapbox/actions/statuses'; import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { Button, HStack, Text } from '../ui'; @@ -42,7 +42,7 @@ const SensitiveContentOverlay = React.forwardRef) => { event.stopPropagation(); - dispatch(toggleStatusHidden(status)); + dispatch(toggleStatusMediaHidden(status)); }; if (visible && !showHideButton) return null; diff --git a/src/features/notifications/components/notification.tsx b/src/features/notifications/components/notification.tsx index c24e66cc04..de8f8649d5 100644 --- a/src/features/notifications/components/notification.tsx +++ b/src/features/notifications/components/notification.tsx @@ -6,7 +6,7 @@ import { mentionCompose } from 'soapbox/actions/compose'; import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { getSettings } from 'soapbox/actions/settings'; -import { toggleStatusHidden } from 'soapbox/actions/statuses'; +import { toggleStatusMediaHidden } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; import RelativeTimestamp from 'soapbox/components/relative-timestamp'; import { HStack, Text, Emoji } from 'soapbox/components/ui'; @@ -267,7 +267,7 @@ const Notification: React.FC = (props) => { const handleHotkeyToggleSensitive = useCallback((e?: KeyboardEvent) => { if (status && typeof status === 'object') { - dispatch(toggleStatusHidden(status)); + dispatch(toggleStatusMediaHidden(status)); } }, [status]); diff --git a/src/features/preferences/index.tsx b/src/features/preferences/index.tsx index e6be6add96..b0da2ec489 100644 --- a/src/features/preferences/index.tsx +++ b/src/features/preferences/index.tsx @@ -153,6 +153,10 @@ const Preferences = () => { /> + }> + + + }> = ({ }; const handleHotkeyToggleSensitive = () => { - dispatch(toggleStatusHidden(status)); + dispatch(toggleStatusMediaHidden(status)); }; const handleMoveUp = (id: string) => { diff --git a/src/locales/en.json b/src/locales/en.json index a6db589aa1..426f42f11a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1180,6 +1180,7 @@ "preferences.fields.preserve_spoilers_label": "Preserve content warning when replying", "preferences.fields.privacy_label": "Default post privacy", "preferences.fields.reduce_motion_label": "Reduce motion in animations", + "preferences.fields.spoilers_display_label": "Automatically expand text behind spoilers", "preferences.fields.system_font_label": "Use system's default font", "preferences.fields.theme": "Theme", "preferences.fields.underline_links_label": "Always underline links in posts", @@ -1474,6 +1475,8 @@ "status.show_less_all": "Show less for all", "status.show_more_all": "Show more for all", "status.show_original": "Show original", + "status.spoiler.collapse": "Collapse", + "status.spoiler.expand": "Expand", "status.title": "Post details", "status.title_direct": "Direct message", "status.translate": "Translate", diff --git a/src/locales/pl.json b/src/locales/pl.json index 15586b1539..5c695bb932 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1180,6 +1180,7 @@ "preferences.fields.preserve_spoilers_label": "Pozostaw ostrzeżenie o zawartości, gdy odpowiadasz na wpis", "preferences.fields.privacy_label": "Prywatność wpisów", "preferences.fields.reduce_motion_label": "Ogranicz ruch w animacjach", + "preferences.fields.spoilers_display_label": "Automatycznie rozwijaj tekst za spoilerami", "preferences.fields.system_font_label": "Używaj domyślnej czcionki systemu", "preferences.fields.theme": "Motyw", "preferences.fields.underline_links_label": "Zawsze podkreślaj odnośniki we wpisach", @@ -1474,6 +1475,8 @@ "status.show_less_all": "Zwiń wszystkie", "status.show_more_all": "Rozwiń wszystkie", "status.show_original": "Pokaż oryginalny wpis", + "status.spoiler.collapse": "Zwiń", + "status.spoiler.expand": "Rozwiń", "status.title": "Wpis", "status.title_direct": "Wiadomość bezpośrednia", "status.translate": "Przetłumacz wpis", diff --git a/src/normalizers/status.ts b/src/normalizers/status.ts index eda1d4fa62..4dc334ebc2 100644 --- a/src/normalizers/status.ts +++ b/src/normalizers/status.ts @@ -26,7 +26,8 @@ type CalculatedValues = { spoilerHtml: string; contentMapHtml?: Record; spoilerMapHtml?: Record; - hidden?: boolean; + expanded?: boolean | null; + hidden?: boolean | null; translation?: Translation | null | false; currentLanguage?: string; }; @@ -67,11 +68,11 @@ const calculateSpoiler = (text: string, emojiMap: any) => DOMPurify.sanitize(emo const calculateStatus = (status: BaseStatus, oldStatus?: OldStatus): CalculatedValues => { if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) { const { - search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, translation, currentLanguage, + search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, expanded, translation, currentLanguage, } = oldStatus; return { - search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, translation, currentLanguage, + search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, expanded, translation, currentLanguage, }; } else { const searchContent = buildSearchContent(status); @@ -159,7 +160,8 @@ const normalizeStatus = (status: BaseStatus & { account: normalizeAccount(status.account), accounts: status.accounts?.map(normalizeAccount), mentions, - hidden: status.sensitive, + expanded: null, + hidden: null, /** Rewrite `

` to empty string. */ content: status.content === '

' ? '' : status.content, filtered: status.filtered?.map(result => result.filter.title), diff --git a/src/reducers/statuses.ts b/src/reducers/statuses.ts index d5a32e9ec9..5234ca2f30 100644 --- a/src/reducers/statuses.ts +++ b/src/reducers/statuses.ts @@ -32,9 +32,9 @@ import { STATUS_CREATE_FAIL, STATUS_DELETE_REQUEST, STATUS_DELETE_FAIL, - STATUS_HIDE, + STATUS_HIDE_MEDIA, STATUS_MUTE_SUCCESS, - STATUS_REVEAL, + STATUS_REVEAL_MEDIA, STATUS_TRANSLATE_FAIL, STATUS_TRANSLATE_REQUEST, STATUS_TRANSLATE_SUCCESS, @@ -42,6 +42,8 @@ import { STATUS_UNFILTER, STATUS_UNMUTE_SUCCESS, STATUS_LANGUAGE_CHANGE, + STATUS_COLLAPSE_SPOILER, + STATUS_EXPAND_SPOILER, } from '../actions/statuses'; import { TIMELINE_DELETE } from '../actions/timelines'; @@ -232,7 +234,7 @@ const statuses = (state = initialState, action: AnyAction): State => { return state.setIn([action.statusId, 'muted'], true); case STATUS_UNMUTE_SUCCESS: return state.setIn([action.statusId, 'muted'], false); - case STATUS_REVEAL: + case STATUS_REVEAL_MEDIA: return state.withMutations(map => { action.statusIds.forEach((id: string) => { if (!(state.get(id) === undefined)) { @@ -240,7 +242,7 @@ const statuses = (state = initialState, action: AnyAction): State => { } }); }); - case STATUS_HIDE: + case STATUS_HIDE_MEDIA: return state.withMutations(map => { action.statusIds.forEach((id: string) => { if (!(state.get(id) === undefined)) { @@ -248,6 +250,22 @@ const statuses = (state = initialState, action: AnyAction): State => { } }); }); + case STATUS_EXPAND_SPOILER: + return state.withMutations(map => { + action.statusIds.forEach((id: string) => { + if (!(state.get(id) === undefined)) { + map.setIn([id, 'expanded'], true); + } + }); + }); + case STATUS_COLLAPSE_SPOILER: + return state.withMutations(map => { + action.statusIds.forEach((id: string) => { + if (!(state.get(id) === undefined)) { + map.setIn([id, 'expanded'], false); + } + }); + }); case STATUS_DELETE_REQUEST: return decrementReplyCount(state, action.params); case STATUS_DELETE_FAIL: diff --git a/src/schemas/soapbox/settings.ts b/src/schemas/soapbox/settings.ts index c9d879d757..de6a0bccf8 100644 --- a/src/schemas/soapbox/settings.ts +++ b/src/schemas/soapbox/settings.ts @@ -15,6 +15,7 @@ const settingsSchema = z.object({ underlineLinks: z.boolean().catch(false), autoPlayGif: z.boolean().catch(true), displayMedia: z.enum(['default', 'hide_all', 'show_all']).catch('default'), + displaySpoilers: z.boolean().catch(false), preserveSpoilers: z.boolean().catch(false), unfollowModal: z.boolean().catch(false), boostModal: z.boolean().catch(false),