Add rendering of quote posts in web UI (#34738)
This commit is contained in:
		
					parent
					
						
							
								f1a6f4333a
							
						
					
				
			
			
				commit
				
					
						97b9e8849d
					
				
			
		
					 14 changed files with 219 additions and 43 deletions
				
			
		|  | @ -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 { | |||
|         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}> | ||||
|           {!skipPrepend && prepend} | ||||
| 
 | ||||
|           <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}> | ||||
|           <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted, 'status--is-quote': isQuotedPost })} data-id={status.get('id')}> | ||||
|             {(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />} | ||||
| 
 | ||||
|             <div onClick={this.handleHeaderClick} onAuxClick={this.handleHeaderClick} className='status__info'> | ||||
|  | @ -576,12 +576,16 @@ class Status extends ImmutablePureComponent { | |||
|                   {...statusContentProps} | ||||
|                 /> | ||||
| 
 | ||||
|                 {children} | ||||
| 
 | ||||
|                 {media} | ||||
|                 {hashtagBar} | ||||
|               </> | ||||
|             )} | ||||
| 
 | ||||
|             <StatusActionBar scrollKey={scrollKey} status={status} account={account}  {...other} /> | ||||
|             {!isQuotedPost && | ||||
|               <StatusActionBar scrollKey={scrollKey} status={status} account={account}  {...other} /> | ||||
|             } | ||||
|           </div> | ||||
|         </div> | ||||
|       </HotKeys> | ||||
|  |  | |||
|  | @ -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 ( | ||||
|             <StatusContainer | ||||
|             <StatusQuoteManager | ||||
|               key={statusId} | ||||
|               id={statusId} | ||||
|               onMoveUp={this.handleMoveUp} | ||||
|  | @ -130,7 +130,7 @@ export default class StatusList extends ImmutablePureComponent { | |||
| 
 | ||||
|     if (scrollableContent && featuredStatusIds) { | ||||
|       scrollableContent = featuredStatusIds.map(statusId => ( | ||||
|         <StatusContainer | ||||
|         <StatusQuoteManager | ||||
|           key={`f-${statusId}`} | ||||
|           id={statusId} | ||||
|           featured | ||||
|  |  | |||
							
								
								
									
										117
									
								
								app/javascript/mastodon/components/status_quoted.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								app/javascript/mastodon/components/status_quoted.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,117 @@ | |||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| 
 | ||||
| import type { Map as ImmutableMap } from 'immutable'; | ||||
| 
 | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import StatusContainer from 'mastodon/containers/status_container'; | ||||
| import { useAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| import QuoteIcon from '../../images/quote.svg?react'; | ||||
| 
 | ||||
| const QuoteWrapper: React.FC<{ | ||||
|   isError?: boolean; | ||||
|   children: React.ReactNode; | ||||
| }> = ({ isError, children }) => { | ||||
|   return ( | ||||
|     <div | ||||
|       className={classNames('status__quote', { | ||||
|         'status__quote--error': isError, | ||||
|       })} | ||||
|     > | ||||
|       <Icon id='quote' icon={QuoteIcon} className='status__quote-icon' /> | ||||
|       {children} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 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 = ( | ||||
|       <FormattedMessage | ||||
|         id='status.quote_error.removed' | ||||
|         defaultMessage='This post was removed by its author.' | ||||
|       /> | ||||
|     ); | ||||
|   } else if (state === 'unauthorized') { | ||||
|     quoteError = ( | ||||
|       <FormattedMessage | ||||
|         id='status.quote_error.unauthorized' | ||||
|         defaultMessage='This post cannot be displayed as you are not authorized to view it.' | ||||
|       /> | ||||
|     ); | ||||
|   } else if (state === 'pending') { | ||||
|     quoteError = ( | ||||
|       <FormattedMessage | ||||
|         id='status.quote_error.pending_approval' | ||||
|         defaultMessage='This post is pending approval from the original author.' | ||||
|       /> | ||||
|     ); | ||||
|   } else if (state === 'rejected' || state === 'revoked') { | ||||
|     quoteError = ( | ||||
|       <FormattedMessage | ||||
|         id='status.quote_error.rejected' | ||||
|         defaultMessage='This post cannot be displayed as the original author does not allow it to be quoted.' | ||||
|       /> | ||||
|     ); | ||||
|   } else if (!status || !quotedStatusId) { | ||||
|     quoteError = ( | ||||
|       <FormattedMessage | ||||
|         id='status.quote_error.not_found' | ||||
|         defaultMessage='This post cannot be displayed.' | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (quoteError) { | ||||
|     return <QuoteWrapper isError>{quoteError}</QuoteWrapper>; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <QuoteWrapper> | ||||
|       <StatusContainer | ||||
|         // @ts-expect-error Status isn't typed yet
 | ||||
|         isQuotedPost | ||||
|         id={quotedStatusId} | ||||
|         avatarSize={40} | ||||
|       /> | ||||
|     </QuoteWrapper> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 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 ( | ||||
|       <StatusContainer {...props}> | ||||
|         <QuotedStatus quote={quote} /> | ||||
|       </StatusContainer> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return <StatusContainer {...props} />; | ||||
| }; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue