From 97b9e8849d07051edfeb097945879f554b0370af Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 21 May 2025 17:50:45 +0200 Subject: [PATCH] Add rendering of quote posts in web UI (#34738) --- .../mastodon/actions/importer/index.js | 4 + .../mastodon/actions/importer/normalizer.js | 8 ++ app/javascript/mastodon/components/status.jsx | 14 ++- .../mastodon/components/status_list.jsx | 6 +- .../mastodon/components/status_quoted.tsx | 117 ++++++++++++++++++ .../features/account_featured/index.tsx | 5 +- .../notifications/components/notification.jsx | 14 +-- .../components/notification_with_status.tsx | 5 +- .../mastodon/features/search/index.tsx | 8 +- .../status/components/detailed_status.tsx | 5 + .../mastodon/features/status/index.jsx | 6 +- app/javascript/mastodon/locales/en.json | 5 + .../styles/mastodon/components.scss | 61 +++++++-- .../styles/mastodon/css_variables.scss | 4 + 14 files changed, 219 insertions(+), 43 deletions(-) create mode 100644 app/javascript/mastodon/components/status_quoted.tsx diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index becbdb88c3..5854482dc5 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -69,6 +69,10 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } + if (status.quote?.quoted_status) { + processStatus(status.quote.quoted_status); + } + if (status.poll?.id) { pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id])); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 2c583f86d4..330da74000 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -23,12 +23,20 @@ export function normalizeFilterResult(result) { export function normalizeStatus(status, normalOldStatus) { const normalStatus = { ...status }; + normalStatus.account = status.account.id; if (status.reblog && status.reblog.id) { normalStatus.reblog = status.reblog.id; } + if (status.quote?.quoted_status ?? status.quote?.quoted_status_id) { + normalStatus.quote = { + ...status.quote, + quoted_status: status.quote.quoted_status?.id ?? status.quote?.quoted_status_id, + }; + } + if (status.poll && status.poll.id) { normalStatus.poll = status.poll.id; } diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 820b24cd6f..26d9c6d33f 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -5,14 +5,12 @@ import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; - import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { HotKeys } from 'react-hotkeys'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; -import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import { ContentWarning } from 'mastodon/components/content_warning'; import { FilterWarning } from 'mastodon/components/filter_warning'; @@ -88,6 +86,7 @@ class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.record, + children: PropTypes.node, previousId: PropTypes.string, nextInReplyToId: PropTypes.string, rootId: PropTypes.string, @@ -115,6 +114,7 @@ class Status extends ImmutablePureComponent { onMoveUp: PropTypes.func, onMoveDown: PropTypes.func, showThread: PropTypes.bool, + isQuotedPost: PropTypes.bool, getScrollPosition: PropTypes.func, updateScrollBottom: PropTypes.func, cacheMediaWidth: PropTypes.func, @@ -372,7 +372,7 @@ class Status extends ImmutablePureComponent { }; render () { - const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props; + const { intl, hidden, featured, unfocusable, unread, showThread, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props; let { status, account, ...other } = this.props; @@ -543,7 +543,7 @@ class Status extends ImmutablePureComponent {
{!skipPrepend && prepend} -
+
{(connectReply || connectUp || connectToRoot) &&
}
@@ -576,12 +576,16 @@ class Status extends ImmutablePureComponent { {...statusContentProps} /> + {children} + {media} {hashtagBar} )} - + {!isQuotedPost && + + }
diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index 3091e2a2a0..390659e9b6 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -9,7 +9,7 @@ import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator'; import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; -import StatusContainer from '../containers/status_container'; +import { StatusQuoteManager } from '../components/status_quoted'; import { LoadGap } from './load_gap'; import ScrollableList from './scrollable_list'; @@ -113,7 +113,7 @@ export default class StatusList extends ImmutablePureComponent { ); default: return ( - ( - = ({ isError, children }) => { + return ( +
+ + {children} +
+ ); +}; + +type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>; + +export const QuotedStatus: React.FC<{ quote: QuoteMap }> = ({ quote }) => { + const quotedStatusId = quote.get('quoted_status'); + const state = quote.get('state'); + const status = useAppSelector((state) => + quotedStatusId ? state.statuses.get(quotedStatusId) : undefined, + ); + + let quoteError: React.ReactNode | null = null; + + if (state === 'deleted') { + quoteError = ( + + ); + } else if (state === 'unauthorized') { + quoteError = ( + + ); + } else if (state === 'pending') { + quoteError = ( + + ); + } else if (state === 'rejected' || state === 'revoked') { + quoteError = ( + + ); + } else if (!status || !quotedStatusId) { + quoteError = ( + + ); + } + + if (quoteError) { + return {quoteError}; + } + + return ( + + + + ); +}; + +interface StatusQuoteManagerProps { + id: string; + [key: string]: unknown; +} + +/** + * This wrapper component takes a status ID and, if the associated status + * is a quote post, it renders the quote into `StatusContainer` as a child. + * It passes all other props through to `StatusContainer`. + */ + +export const StatusQuoteManager = (props: StatusQuoteManagerProps) => { + const status = useAppSelector((state) => state.statuses.get(props.id)); + const quote = status?.get('quote') as QuoteMap | undefined; + + if (quote) { + return ( + + + + ); + } + + return ; +}; diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index d516bc3411..c473d311c1 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -14,7 +14,7 @@ import { Account } from 'mastodon/components/account'; import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { RemoteHint } from 'mastodon/components/remote_hint'; -import StatusContainer from 'mastodon/containers/status_container'; +import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import Column from 'mastodon/features/ui/components/column'; @@ -142,9 +142,8 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ /> {featuredStatusIds.map((statusId) => ( - diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index 00963b2274..86431f62fd 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -20,7 +20,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import { Account } from 'mastodon/components/account'; import { Icon } from 'mastodon/components/icon'; -import StatusContainer from 'mastodon/containers/status_container'; +import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { me } from 'mastodon/initial_state'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; @@ -175,7 +175,7 @@ class Notification extends ImmutablePureComponent { renderMention (notification) { return ( -
-
-
- - - - is not yet typed + const renderStatuses = (statusIds: string[]) => hidePeek(statusIds).map((id) => ( - // @ts-expect-error inferred props are wrong - + )); type SearchType = 'all' | ApiSearchType; @@ -190,8 +189,7 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({ onClickMore={handleSelectStatuses} > {results.statuses.slice(0, INITIAL_DISPLAY).map((id) => ( - // @ts-expect-error inferred props are wrong - + ))} )} diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index 75d995b1e0..aa79a82f68 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -26,6 +26,7 @@ import { IconLogo } from 'mastodon/components/logo'; import MediaGallery from 'mastodon/components/media_gallery'; import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder'; import StatusContent from 'mastodon/components/status_content'; +import { QuotedStatus } from 'mastodon/components/status_quoted'; import { VisibilityIcon } from 'mastodon/components/visibility_icon'; import { Audio } from 'mastodon/features/audio'; import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task'; @@ -371,6 +372,10 @@ export const DetailedStatus: React.FC<{ {...(statusContentProps as any)} /> + {status.get('quote') && ( + + )} + {media} {hashtagBar} diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 7da2df3742..0f02e7b50f 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -6,8 +6,6 @@ import classNames from 'classnames'; import { Helmet } from 'react-helmet'; import { withRouter } from 'react-router-dom'; -import { createSelector } from '@reduxjs/toolkit'; -import { List as ImmutableList } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; @@ -62,7 +60,7 @@ import { } from '../../actions/statuses'; import ColumnHeader from '../../components/column_header'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; -import StatusContainer from '../../containers/status_container'; +import { StatusQuoteManager } from '../../components/status_quoted'; import { deleteModal } from '../../initial_state'; import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; import { getAncestorsIds, getDescendantsIds } from 'mastodon/selectors/contexts'; @@ -477,7 +475,7 @@ class Status extends ImmutablePureComponent { const { params: { statusId } } = this.props; return list.map((id, i) => ( - [data-popper-placement] { } } + &--is-quote { + border: none; + } + &--in-thread { - $thread-margin: 46px + 10px; + --thread-margin: calc(46px + 8px); border-bottom: 0; @@ -1508,16 +1512,16 @@ body > [data-popper-placement] { .hashtag-bar, .content-warning, .filter-warning { - margin-inline-start: $thread-margin; - width: calc(100% - $thread-margin); + margin-inline-start: var(--thread-margin); + width: calc(100% - var(--thread-margin)); } .more-from-author { - width: calc(100% - $thread-margin + 2px); + width: calc(100% - var(--thread-margin) + 2px); } .status__content__read-more-button { - margin-inline-start: $thread-margin; + margin-inline-start: var(--thread-margin); } } @@ -1873,6 +1877,41 @@ body > [data-popper-placement] { } } +.status__quote { + position: relative; + margin-block-start: 16px; + margin-inline-start: 56px; + border-radius: 8px; + color: var(--nested-card-text); + background: var(--nested-card-background); + border: var(--nested-card-border); +} + +.status__quote--error { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + font-size: 15px; +} + +.status__quote-icon { + position: absolute; + inset-block-start: 18px; + inset-inline-start: -50px; + display: block; + width: 26px; + height: 26px; + padding: 5px; + color: #6a49ba; + z-index: 10; + + .status__quote--error & { + inset-block-start: 50%; + transform: translateY(-50%); + } +} + .detailed-status__link { display: inline-flex; align-items: center; @@ -2306,11 +2345,6 @@ a.account__display-name { } } -.status__avatar { - width: 46px; - height: 46px; -} - .muted { .status__content, .status__content p, @@ -10515,6 +10549,7 @@ noscript { line-height: 22px; color: $darker-text-color; -webkit-line-clamp: 4; + line-clamp: 4; -webkit-box-orient: vertical; max-height: none; overflow: hidden; @@ -10818,9 +10853,9 @@ noscript { .content-warning { display: block; box-sizing: border-box; - background: rgba($ui-highlight-color, 0.05); - color: $secondary-text-color; - border: 1px solid rgba($ui-highlight-color, 0.15); + background: var(--nested-card-background); + color: var(--nested-card-text); + border: var(--nested-card-border); border-radius: 8px; padding: 8px (5px + 8px); position: relative; diff --git a/app/javascript/styles/mastodon/css_variables.scss b/app/javascript/styles/mastodon/css_variables.scss index 782e08e283..413efca3f6 100644 --- a/app/javascript/styles/mastodon/css_variables.scss +++ b/app/javascript/styles/mastodon/css_variables.scss @@ -27,6 +27,10 @@ --rich-text-container-color: rgba(87, 24, 60, 100%); --rich-text-text-color: rgba(255, 175, 212, 100%); --rich-text-decorations-color: rgba(128, 58, 95, 100%); + --nested-card-background: color(from #{$ui-highlight-color} srgb r g b / 5%); + --nested-card-text: #{$secondary-text-color}; + --nested-card-border: 1px solid + color(from #{$ui-highlight-color} srgb r g b / 15%); --input-placeholder-color: #{$dark-text-color}; --input-background-color: var(--surface-variant-background-color); --on-input-color: #{$secondary-text-color};