Add show more/less toggle for entire threads in web UI (#6733)
Fix #1258
This commit is contained in:
		| @@ -23,6 +23,9 @@ 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 function fetchStatusRequest(id, skipLoading) { | ||||
|   return { | ||||
|     type: STATUS_FETCH_REQUEST, | ||||
| @@ -215,3 +218,25 @@ 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, | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -19,10 +19,11 @@ export default class ColumnHeader extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     title: PropTypes.node.isRequired, | ||||
|     icon: PropTypes.string.isRequired, | ||||
|     title: PropTypes.node, | ||||
|     icon: PropTypes.string, | ||||
|     active: PropTypes.bool, | ||||
|     multiColumn: PropTypes.bool, | ||||
|     extraButton: PropTypes.node, | ||||
|     showBackButton: PropTypes.bool, | ||||
|     children: PropTypes.node, | ||||
|     pinned: PropTypes.bool, | ||||
| @@ -63,7 +64,7 @@ export default class ColumnHeader extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { title, icon, active, children, pinned, onPin, multiColumn, showBackButton, intl: { formatMessage } } = this.props; | ||||
|     const { title, icon, active, children, pinned, onPin, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props; | ||||
|     const { collapsed, animating } = this.state; | ||||
|  | ||||
|     const wrapperClassName = classNames('column-header__wrapper', { | ||||
| @@ -125,19 +126,26 @@ export default class ColumnHeader extends React.PureComponent { | ||||
|     } | ||||
|  | ||||
|     if (children || multiColumn) { | ||||
|       collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; | ||||
|       collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; | ||||
|     } | ||||
|  | ||||
|     const hasTitle = icon && title; | ||||
|  | ||||
|     return ( | ||||
|       <div className={wrapperClassName}> | ||||
|         <h1 className={buttonClassName}> | ||||
|           <button onClick={this.handleTitleClick}> | ||||
|             <i className={`fa fa-fw fa-${icon} column-header__icon`} /> | ||||
|             {title} | ||||
|           </button> | ||||
|           {hasTitle && ( | ||||
|             <button onClick={this.handleTitleClick}> | ||||
|               <i className={`fa fa-fw fa-${icon} column-header__icon`} /> | ||||
|               {title} | ||||
|             </button> | ||||
|           )} | ||||
|  | ||||
|           {!hasTitle && backButton} | ||||
|  | ||||
|           <div className='column-header__buttons'> | ||||
|             {backButton} | ||||
|             {hasTitle && backButton} | ||||
|             {extraButton} | ||||
|             {collapseButton} | ||||
|           </div> | ||||
|         </h1> | ||||
|   | ||||
| @@ -37,16 +37,13 @@ export default class Status extends ImmutablePureComponent { | ||||
|     onBlock: PropTypes.func, | ||||
|     onEmbed: PropTypes.func, | ||||
|     onHeightChange: PropTypes.func, | ||||
|     onToggleHidden: PropTypes.func, | ||||
|     muted: PropTypes.bool, | ||||
|     hidden: PropTypes.bool, | ||||
|     onMoveUp: PropTypes.func, | ||||
|     onMoveDown: PropTypes.func, | ||||
|   }; | ||||
|  | ||||
|   state = { | ||||
|     isExpanded: false, | ||||
|   } | ||||
|  | ||||
|   // Avoid checking props that are functions (and whose equality will always | ||||
|   // evaluate to false. See react-immutable-pure-component for usage. | ||||
|   updateOnProps = [ | ||||
| @@ -56,8 +53,6 @@ export default class Status extends ImmutablePureComponent { | ||||
|     'hidden', | ||||
|   ] | ||||
|  | ||||
|   updateOnStates = ['isExpanded'] | ||||
|  | ||||
|   handleClick = () => { | ||||
|     if (!this.context.router) { | ||||
|       return; | ||||
| @@ -76,7 +71,7 @@ export default class Status extends ImmutablePureComponent { | ||||
|   } | ||||
|  | ||||
|   handleExpandedToggle = () => { | ||||
|     this.setState({ isExpanded: !this.state.isExpanded }); | ||||
|     this.props.onToggleHidden(this._properStatus()); | ||||
|   }; | ||||
|  | ||||
|   renderLoadingMediaGallery () { | ||||
| @@ -140,7 +135,6 @@ export default class Status extends ImmutablePureComponent { | ||||
|     let statusAvatar, prepend; | ||||
|  | ||||
|     const { hidden, featured } = this.props; | ||||
|     const { isExpanded } = this.state; | ||||
|  | ||||
|     let { status, account, ...other } = this.props; | ||||
|  | ||||
| @@ -248,7 +242,7 @@ export default class Status extends ImmutablePureComponent { | ||||
|               </a> | ||||
|             </div> | ||||
|  | ||||
|             <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> | ||||
|             <StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} /> | ||||
|  | ||||
|             {media} | ||||
|  | ||||
|   | ||||
| @@ -15,7 +15,13 @@ import { | ||||
|   unpin, | ||||
| } from '../actions/interactions'; | ||||
| import { blockAccount } from '../actions/accounts'; | ||||
| import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; | ||||
| import { | ||||
|   muteStatus, | ||||
|   unmuteStatus, | ||||
|   deleteStatus, | ||||
|   hideStatus, | ||||
|   revealStatus, | ||||
| } from '../actions/statuses'; | ||||
| import { initMuteModal } from '../actions/mutes'; | ||||
| import { initReport } from '../actions/reports'; | ||||
| import { openModal } from '../actions/modal'; | ||||
| @@ -128,6 +134,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   onToggleHidden (status) { | ||||
|     if (status.get('hidden')) { | ||||
|       dispatch(revealStatus(status.get('id'))); | ||||
|     } else { | ||||
|       dispatch(hideStatus(status.get('id'))); | ||||
|     } | ||||
|   }, | ||||
|  | ||||
| }); | ||||
|  | ||||
| export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | ||||
|   | ||||
| @@ -22,6 +22,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | ||||
|     status: ImmutablePropTypes.map.isRequired, | ||||
|     onOpenMedia: PropTypes.func.isRequired, | ||||
|     onOpenVideo: PropTypes.func.isRequired, | ||||
|     onToggleHidden: PropTypes.func.isRequired, | ||||
|   }; | ||||
|  | ||||
|   handleAccountClick = (e) => { | ||||
| @@ -37,6 +38,10 @@ export default class DetailedStatus extends ImmutablePureComponent { | ||||
|     this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); | ||||
|   } | ||||
|  | ||||
|   handleExpandedToggle = () => { | ||||
|     this.props.onToggleHidden(this.props.status); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; | ||||
|  | ||||
| @@ -105,7 +110,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | ||||
|           <DisplayName account={status.get('account')} /> | ||||
|         </a> | ||||
|  | ||||
|         <StatusContent status={status} /> | ||||
|         <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} /> | ||||
|  | ||||
|         {media} | ||||
|  | ||||
|   | ||||
| @@ -21,12 +21,19 @@ import { | ||||
|   mentionCompose, | ||||
| } from '../../actions/compose'; | ||||
| import { blockAccount } from '../../actions/accounts'; | ||||
| import { muteStatus, unmuteStatus, deleteStatus } from '../../actions/statuses'; | ||||
| import { | ||||
|   muteStatus, | ||||
|   unmuteStatus, | ||||
|   deleteStatus, | ||||
|   hideStatus, | ||||
|   revealStatus, | ||||
| } from '../../actions/statuses'; | ||||
| import { initMuteModal } from '../../actions/mutes'; | ||||
| import { initReport } from '../../actions/reports'; | ||||
| import { makeGetStatus } from '../../selectors'; | ||||
| import { ScrollContainer } from 'react-router-scroll-4'; | ||||
| import ColumnBackButton from '../../components/column_back_button'; | ||||
| import ColumnHeader from '../../components/column_header'; | ||||
| import StatusContainer from '../../containers/status_container'; | ||||
| import { openModal } from '../../actions/modal'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| @@ -39,6 +46,8 @@ const messages = defineMessages({ | ||||
|   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, | ||||
|   deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, | ||||
|   blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, | ||||
|   revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, | ||||
|   hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' }, | ||||
| }); | ||||
|  | ||||
| const makeMapStateToProps = () => { | ||||
| @@ -163,6 +172,25 @@ export default class Status extends ImmutablePureComponent { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleToggleHidden = (status) => { | ||||
|     if (status.get('hidden')) { | ||||
|       this.props.dispatch(revealStatus(status.get('id'))); | ||||
|     } else { | ||||
|       this.props.dispatch(hideStatus(status.get('id'))); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleToggleAll = () => { | ||||
|     const { status, ancestorsIds, descendantsIds } = this.props; | ||||
|     const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS()); | ||||
|  | ||||
|     if (status.get('hidden')) { | ||||
|       this.props.dispatch(revealStatus(statusIds)); | ||||
|     } else { | ||||
|       this.props.dispatch(hideStatus(statusIds)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleBlockClick = (account) => { | ||||
|     const { dispatch, intl } = this.props; | ||||
|  | ||||
| @@ -293,7 +321,7 @@ export default class Status extends ImmutablePureComponent { | ||||
|  | ||||
|   render () { | ||||
|     let ancestors, descendants; | ||||
|     const { status, ancestorsIds, descendantsIds } = this.props; | ||||
|     const { status, ancestorsIds, descendantsIds, intl } = this.props; | ||||
|     const { fullscreen } = this.state; | ||||
|  | ||||
|     if (status === null) { | ||||
| @@ -325,7 +353,12 @@ export default class Status extends ImmutablePureComponent { | ||||
|  | ||||
|     return ( | ||||
|       <Column> | ||||
|         <ColumnBackButton /> | ||||
|         <ColumnHeader | ||||
|           showBackButton | ||||
|           extraButton={( | ||||
|             <button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><i className={`fa fa-${status.get('hidden') ? 'eye-slash' : 'eye'}`} /></button> | ||||
|           )} | ||||
|         /> | ||||
|  | ||||
|         <ScrollContainer scrollKey='thread'> | ||||
|           <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}> | ||||
| @@ -337,6 +370,7 @@ export default class Status extends ImmutablePureComponent { | ||||
|                   status={status} | ||||
|                   onOpenVideo={this.handleOpenVideo} | ||||
|                   onOpenMedia={this.handleOpenMedia} | ||||
|                   onToggleHidden={this.handleToggleHidden} | ||||
|                 /> | ||||
|  | ||||
|                 <ActionBar | ||||
|   | ||||
| @@ -15,6 +15,8 @@ import { | ||||
|   CONTEXT_FETCH_SUCCESS, | ||||
|   STATUS_MUTE_SUCCESS, | ||||
|   STATUS_UNMUTE_SUCCESS, | ||||
|   STATUS_REVEAL, | ||||
|   STATUS_HIDE, | ||||
| } from '../actions/statuses'; | ||||
| import { | ||||
|   TIMELINE_REFRESH_SUCCESS, | ||||
| @@ -62,8 +64,9 @@ const normalizeStatus = (state, status) => { | ||||
|   }, {}); | ||||
|  | ||||
|   normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; | ||||
|   normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); | ||||
|   normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); | ||||
|   normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap); | ||||
|   normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); | ||||
|   normalStatus.hidden       = normalStatus.sensitive; | ||||
|  | ||||
|   return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus))); | ||||
| }; | ||||
| @@ -111,6 +114,14 @@ 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 => map.setIn([id, 'hidden'], false)); | ||||
|     }); | ||||
|   case STATUS_HIDE: | ||||
|     return state.withMutations(map => { | ||||
|       action.ids.forEach(id => map.setIn([id, 'hidden'], true)); | ||||
|     }); | ||||
|   case TIMELINE_REFRESH_SUCCESS: | ||||
|   case TIMELINE_EXPAND_SUCCESS: | ||||
|   case CONTEXT_FETCH_SUCCESS: | ||||
|   | ||||
| @@ -2515,6 +2515,10 @@ a.status-card { | ||||
|     flex: 1; | ||||
|   } | ||||
|  | ||||
|   & > .column-header__back-button { | ||||
|     color: $ui-highlight-color; | ||||
|   } | ||||
|  | ||||
|   &.active { | ||||
|     box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3); | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user