Merge branch 'main' into glitch-soc/merge-upstream
Conflicts: - `app/views/settings/preferences/appearance/show.html.haml`: Upstream renamed some helper functions that were used in a part of the settings page which glitch-soc slightly changed the layout of. Ported the change.
This commit is contained in:
		
						commit
						322e907e04
					
				
					 28 changed files with 645 additions and 141 deletions
				
			
		|  | @ -7,7 +7,7 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController | |||
|   before_action :set_status | ||||
| 
 | ||||
|   def show | ||||
|     render json: @status.edits, each_serializer: REST::StatusEditSerializer | ||||
|     render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
|  |  | |||
|  | @ -209,8 +209,8 @@ module LanguagesHelper | |||
|     'zh-TW': '繁體中文(臺灣)', | ||||
|   }.freeze | ||||
| 
 | ||||
|   def human_locale(locale) | ||||
|     if locale == 'und' | ||||
|   def native_locale_name(locale) | ||||
|     if locale.blank? || locale == 'und' | ||||
|       I18n.t('generic.none') | ||||
|     elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) | ||||
|       supported_locale[1] | ||||
|  | @ -221,6 +221,16 @@ module LanguagesHelper | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def standard_locale_name(locale) | ||||
|     if locale.blank? | ||||
|       I18n.t('generic.none') | ||||
|     elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) | ||||
|       supported_locale[0] | ||||
|     else | ||||
|       locale | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def valid_locale_or_nil(str) | ||||
|     return if str.blank? | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										37
									
								
								app/javascript/mastodon/actions/history.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/javascript/mastodon/actions/history.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| import api from '../api'; | ||||
| import { importFetchedAccounts } from './importer'; | ||||
| 
 | ||||
| export const HISTORY_FETCH_REQUEST = 'HISTORY_FETCH_REQUEST'; | ||||
| export const HISTORY_FETCH_SUCCESS = 'HISTORY_FETCH_SUCCESS'; | ||||
| export const HISTORY_FETCH_FAIL    = 'HISTORY_FETCH_FAIL'; | ||||
| 
 | ||||
| export const fetchHistory = statusId => (dispatch, getState) => { | ||||
|   const loading = getState().getIn(['history', statusId, 'loading']); | ||||
| 
 | ||||
|   if (loading) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   dispatch(fetchHistoryRequest(statusId)); | ||||
| 
 | ||||
|   api(getState).get(`/api/v1/statuses/${statusId}/history`).then(({ data }) => { | ||||
|     dispatch(importFetchedAccounts(data.map(x => x.account))); | ||||
|     dispatch(fetchHistorySuccess(statusId, data)); | ||||
|   }).catch(error => dispatch(fetchHistoryFail(error))); | ||||
| }; | ||||
| 
 | ||||
| export const fetchHistoryRequest = statusId => ({ | ||||
|   type: HISTORY_FETCH_REQUEST, | ||||
|   statusId, | ||||
| }); | ||||
| 
 | ||||
| export const fetchHistorySuccess = (statusId, history) => ({ | ||||
|   type: HISTORY_FETCH_SUCCESS, | ||||
|   statusId, | ||||
|   history, | ||||
| }); | ||||
| 
 | ||||
| export const fetchHistoryFail = error => ({ | ||||
|   type: HISTORY_FETCH_FAIL, | ||||
|   error, | ||||
| }); | ||||
|  | @ -6,6 +6,8 @@ import Overlay from 'react-overlays/lib/Overlay'; | |||
| import Motion from '../features/ui/util/optional_motion'; | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| import { supportsPassiveEvents } from 'detect-passive-events'; | ||||
| import classNames from 'classnames'; | ||||
| import { CircularProgress } from 'mastodon/components/loading_indicator'; | ||||
| 
 | ||||
| const listenerOptions = supportsPassiveEvents ? { passive: true } : false; | ||||
| let id = 0; | ||||
|  | @ -17,13 +19,18 @@ class DropdownMenu extends React.PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     items: PropTypes.array.isRequired, | ||||
|     items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, | ||||
|     loading: PropTypes.bool, | ||||
|     scrollable: PropTypes.bool, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     style: PropTypes.object, | ||||
|     placement: PropTypes.string, | ||||
|     arrowOffsetLeft: PropTypes.string, | ||||
|     arrowOffsetTop: PropTypes.string, | ||||
|     openedViaKeyboard: PropTypes.bool, | ||||
|     renderItem: PropTypes.func, | ||||
|     renderHeader: PropTypes.func, | ||||
|     onItemClick: PropTypes.func.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
|  | @ -45,9 +52,11 @@ class DropdownMenu extends React.PureComponent { | |||
|     document.addEventListener('click', this.handleDocumentClick, false); | ||||
|     document.addEventListener('keydown', this.handleKeyDown, false); | ||||
|     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||
| 
 | ||||
|     if (this.focusedItem && this.props.openedViaKeyboard) { | ||||
|       this.focusedItem.focus({ preventScroll: true }); | ||||
|     } | ||||
| 
 | ||||
|     this.setState({ mounted: true }); | ||||
|   } | ||||
| 
 | ||||
|  | @ -66,7 +75,7 @@ class DropdownMenu extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   handleKeyDown = e => { | ||||
|     const items = Array.from(this.node.getElementsByTagName('a')); | ||||
|     const items = Array.from(this.node.querySelectorAll('a, button')); | ||||
|     const index = items.indexOf(document.activeElement); | ||||
|     let element = null; | ||||
| 
 | ||||
|  | @ -109,21 +118,11 @@ class DropdownMenu extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   handleClick = e => { | ||||
|     const i = Number(e.currentTarget.getAttribute('data-index')); | ||||
|     const { action, to } = this.props.items[i]; | ||||
| 
 | ||||
|     this.props.onClose(); | ||||
| 
 | ||||
|     if (typeof action === 'function') { | ||||
|       e.preventDefault(); | ||||
|       action(e); | ||||
|     } else if (to) { | ||||
|       e.preventDefault(); | ||||
|       this.context.router.history.push(to); | ||||
|     } | ||||
|     const { onItemClick } = this.props; | ||||
|     onItemClick(e); | ||||
|   } | ||||
| 
 | ||||
|   renderItem (option, i) { | ||||
|   renderItem = (option, i) => { | ||||
|     if (option === null) { | ||||
|       return <li key={`sep-${i}`} className='dropdown-menu__separator' />; | ||||
|     } | ||||
|  | @ -140,9 +139,11 @@ class DropdownMenu extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; | ||||
|     const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props; | ||||
|     const { mounted } = this.state; | ||||
| 
 | ||||
|     let renderItem = this.props.renderItem || this.renderItem; | ||||
| 
 | ||||
|     return ( | ||||
|       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | ||||
|         {({ opacity, scaleX, scaleY }) => ( | ||||
|  | @ -152,9 +153,23 @@ class DropdownMenu extends React.PureComponent { | |||
|           <div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}> | ||||
|             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> | ||||
| 
 | ||||
|             <ul> | ||||
|               {items.map((option, i) => this.renderItem(option, i))} | ||||
|             </ul> | ||||
|             <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}> | ||||
|               {loading && ( | ||||
|                 <CircularProgress size={30} strokeWidth={3.5} /> | ||||
|               )} | ||||
| 
 | ||||
|               {!loading && renderHeader && ( | ||||
|                 <div className='dropdown-menu__container__header'> | ||||
|                   {renderHeader(items)} | ||||
|                 </div> | ||||
|               )} | ||||
| 
 | ||||
|               {!loading && ( | ||||
|                 <ul className={classNames('dropdown-menu__container__list', { 'dropdown-menu__container__list--scrollable': scrollable })}> | ||||
|                   {items.map((option, i) => renderItem(option, i, { onClick: this.handleClick, onKeyPress: this.handleItemKeyPress }))} | ||||
|                 </ul> | ||||
|               )} | ||||
|             </div> | ||||
|           </div> | ||||
|         )} | ||||
|       </Motion> | ||||
|  | @ -170,11 +185,14 @@ export default class Dropdown extends React.PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     icon: PropTypes.string.isRequired, | ||||
|     items: PropTypes.array.isRequired, | ||||
|     size: PropTypes.number.isRequired, | ||||
|     children: PropTypes.node, | ||||
|     icon: PropTypes.string, | ||||
|     items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, | ||||
|     loading: PropTypes.bool, | ||||
|     size: PropTypes.number, | ||||
|     title: PropTypes.string, | ||||
|     disabled: PropTypes.bool, | ||||
|     scrollable: PropTypes.bool, | ||||
|     status: ImmutablePropTypes.map, | ||||
|     isUserTouching: PropTypes.func, | ||||
|     onOpen: PropTypes.func.isRequired, | ||||
|  | @ -182,6 +200,9 @@ export default class Dropdown extends React.PureComponent { | |||
|     dropdownPlacement: PropTypes.string, | ||||
|     openDropdownId: PropTypes.number, | ||||
|     openedViaKeyboard: PropTypes.bool, | ||||
|     renderItem: PropTypes.func, | ||||
|     renderHeader: PropTypes.func, | ||||
|     onItemClick: PropTypes.func, | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
|  | @ -237,17 +258,21 @@ export default class Dropdown extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   handleItemClick = e => { | ||||
|     const { onItemClick } = this.props; | ||||
|     const i = Number(e.currentTarget.getAttribute('data-index')); | ||||
|     const { action, to } = this.props.items[i]; | ||||
|     const item = this.props.items[i]; | ||||
| 
 | ||||
|     this.handleClose(); | ||||
| 
 | ||||
|     if (typeof action === 'function') { | ||||
|     if (typeof onItemClick === 'function') { | ||||
|       e.preventDefault(); | ||||
|       action(); | ||||
|     } else if (to) { | ||||
|       onItemClick(item, i); | ||||
|     } else if (item && typeof item.action === 'function') { | ||||
|       e.preventDefault(); | ||||
|       this.context.router.history.push(to); | ||||
|       item.action(); | ||||
|     } else if (item && item.to) { | ||||
|       e.preventDefault(); | ||||
|       this.context.router.history.push(item.to); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -265,29 +290,67 @@ export default class Dropdown extends React.PureComponent { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   close = () => { | ||||
|     this.handleClose(); | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props; | ||||
|     const { | ||||
|       icon, | ||||
|       items, | ||||
|       size, | ||||
|       title, | ||||
|       disabled, | ||||
|       loading, | ||||
|       scrollable, | ||||
|       dropdownPlacement, | ||||
|       openDropdownId, | ||||
|       openedViaKeyboard, | ||||
|       children, | ||||
|       renderItem, | ||||
|       renderHeader, | ||||
|     } = this.props; | ||||
| 
 | ||||
|     const open = this.state.id === openDropdownId; | ||||
| 
 | ||||
|     const button = children ? React.cloneElement(React.Children.only(children), { | ||||
|       ref: this.setTargetRef, | ||||
|       onClick: this.handleClick, | ||||
|       onMouseDown: this.handleMouseDown, | ||||
|       onKeyDown: this.handleButtonKeyDown, | ||||
|       onKeyPress: this.handleKeyPress, | ||||
|     }) : ( | ||||
|       <IconButton | ||||
|         icon={icon} | ||||
|         title={title} | ||||
|         active={open} | ||||
|         disabled={disabled} | ||||
|         size={size} | ||||
|         ref={this.setTargetRef} | ||||
|         onClick={this.handleClick} | ||||
|         onMouseDown={this.handleMouseDown} | ||||
|         onKeyDown={this.handleButtonKeyDown} | ||||
|         onKeyPress={this.handleKeyPress} | ||||
|       /> | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|       <div> | ||||
|         <IconButton | ||||
|           icon={icon} | ||||
|           title={title} | ||||
|           active={open} | ||||
|           disabled={disabled} | ||||
|           size={size} | ||||
|           ref={this.setTargetRef} | ||||
|           onClick={this.handleClick} | ||||
|           onMouseDown={this.handleMouseDown} | ||||
|           onKeyDown={this.handleButtonKeyDown} | ||||
|           onKeyPress={this.handleKeyPress} | ||||
|         /> | ||||
|       <React.Fragment> | ||||
|         {button} | ||||
| 
 | ||||
|         <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> | ||||
|           <DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} /> | ||||
|           <DropdownMenu | ||||
|             items={items} | ||||
|             loading={loading} | ||||
|             scrollable={scrollable} | ||||
|             onClose={this.handleClose} | ||||
|             openedViaKeyboard={openedViaKeyboard} | ||||
|             renderItem={renderItem} | ||||
|             renderHeader={renderHeader} | ||||
|             onItemClick={this.handleItemClick} | ||||
|           /> | ||||
|         </Overlay> | ||||
|       </div> | ||||
|       </React.Fragment> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,27 @@ | |||
| import { connect } from 'react-redux'; | ||||
| import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_menu'; | ||||
| import { fetchHistory } from 'mastodon/actions/history'; | ||||
| import DropdownMenu from 'mastodon/components/dropdown_menu'; | ||||
| 
 | ||||
| const mapStateToProps = (state, { statusId }) => ({ | ||||
|   dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), | ||||
|   openDropdownId: state.getIn(['dropdown_menu', 'openId']), | ||||
|   openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), | ||||
|   items: state.getIn(['history', statusId, 'items']), | ||||
|   loading: state.getIn(['history', statusId, 'loading']), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = (dispatch, { statusId }) => ({ | ||||
| 
 | ||||
|   onOpen (id, onItemClick, dropdownPlacement, keyboard) { | ||||
|     dispatch(fetchHistory(statusId)); | ||||
|     dispatch(openDropdownMenu(id, dropdownPlacement, keyboard)); | ||||
|   }, | ||||
| 
 | ||||
|   onClose (id) { | ||||
|     dispatch(closeDropdownMenu(id)); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); | ||||
							
								
								
									
										70
									
								
								app/javascript/mastodon/components/edited_timestamp/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								app/javascript/mastodon/components/edited_timestamp/index.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,70 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { FormattedMessage, injectIntl } from 'react-intl'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import DropdownMenu from './containers/dropdown_menu_container'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import RelativeTimestamp from 'mastodon/components/relative_timestamp'; | ||||
| import InlineAccount from 'mastodon/components/inline_account'; | ||||
| 
 | ||||
| const mapDispatchToProps = (dispatch, { statusId }) => ({ | ||||
| 
 | ||||
|   onItemClick (index) { | ||||
|     dispatch(openModal('COMPARE_HISTORY', { index, statusId })); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default @connect(null, mapDispatchToProps) | ||||
| @injectIntl | ||||
| class EditedTimestamp extends React.PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     statusId: PropTypes.string.isRequired, | ||||
|     timestamp: PropTypes.string.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     onItemClick: PropTypes.func.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   handleItemClick = (item, i) => { | ||||
|     const { onItemClick } = this.props; | ||||
|     onItemClick(i); | ||||
|   }; | ||||
| 
 | ||||
|   renderHeader = items => { | ||||
|     return ( | ||||
|       <FormattedMessage id='status.edited_x_times' defaultMessage='Edited {count, plural, one {{count} time} other {{count} times}}' values={{ count: items.size - 1 }} /> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   renderItem = (item, index, { onClick, onKeyPress }) => { | ||||
|     const formattedDate = <RelativeTimestamp timestamp={item.get('created_at')} short={false} />; | ||||
|     const formattedName = <InlineAccount accountId={item.get('account')} />; | ||||
| 
 | ||||
|     const label = item.get('original') ? ( | ||||
|       <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} /> | ||||
|     ) : ( | ||||
|       <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} /> | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|       <li className='dropdown-menu__item edited-timestamp__history__item' key={item.get('created_at')}> | ||||
|         <button data-index={index} onClick={onClick} onKeyPress={onKeyPress}>{label}</button> | ||||
|       </li> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { timestamp, intl, statusId } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}> | ||||
|         <button className='dropdown-menu__text-button'> | ||||
|           <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' /> | ||||
|         </button> | ||||
|       </DropdownMenu> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										34
									
								
								app/javascript/mastodon/components/inline_account.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/javascript/mastodon/components/inline_account.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { makeGetAccount } from 'mastodon/selectors'; | ||||
| import Avatar from 'mastodon/components/avatar'; | ||||
| 
 | ||||
| const makeMapStateToProps = () => { | ||||
|   const getAccount = makeGetAccount(); | ||||
| 
 | ||||
|   const mapStateToProps = (state, { accountId }) => ({ | ||||
|     account: getAccount(state, accountId), | ||||
|   }); | ||||
| 
 | ||||
|   return mapStateToProps; | ||||
| }; | ||||
| 
 | ||||
| export default @connect(makeMapStateToProps) | ||||
| class InlineAccount extends React.PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { account } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <span className='inline-account'> | ||||
|         <Avatar size={13} account={account} /> <strong>{account.get('username')}</strong> | ||||
|       </span> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,10 +1,31 @@ | |||
| import React from 'react'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| export const CircularProgress = ({ size, strokeWidth }) => { | ||||
|   const viewBox = `0 0 ${size} ${size}`; | ||||
|   const radius  = (size - strokeWidth) / 2; | ||||
| 
 | ||||
|   return ( | ||||
|     <svg width={size} heigh={size} viewBox={viewBox} className='circular-progress' role='progressbar'> | ||||
|       <circle | ||||
|         fill='none' | ||||
|         cx={size / 2} | ||||
|         cy={size / 2} | ||||
|         r={radius} | ||||
|         strokeWidth={`${strokeWidth}px`} | ||||
|       /> | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| CircularProgress.propTypes = { | ||||
|   size: PropTypes.number.isRequired, | ||||
|   strokeWidth: PropTypes.number.isRequired, | ||||
| }; | ||||
| 
 | ||||
| const LoadingIndicator = () => ( | ||||
|   <div className='loading-indicator'> | ||||
|     <div className='loading-indicator__figure' /> | ||||
|     <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> | ||||
|     <CircularProgress size={50} strokeWidth={6} /> | ||||
|   </div> | ||||
| ); | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,10 +5,15 @@ import PropTypes from 'prop-types'; | |||
| const messages = defineMessages({ | ||||
|   today: { id: 'relative_time.today', defaultMessage: 'today' }, | ||||
|   just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, | ||||
|   just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' }, | ||||
|   seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, | ||||
|   seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' }, | ||||
|   minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, | ||||
|   minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' }, | ||||
|   hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, | ||||
|   hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' }, | ||||
|   days: { id: 'relative_time.days', defaultMessage: '{number}d' }, | ||||
|   days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' }, | ||||
|   moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, | ||||
|   seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, | ||||
|   minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, | ||||
|  | @ -66,7 +71,7 @@ const getUnitDelay = units => { | |||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const timeAgoString = (intl, date, now, year, timeGiven = true) => { | ||||
| export const timeAgoString = (intl, date, now, year, timeGiven, short) => { | ||||
|   const delta = now - date.getTime(); | ||||
| 
 | ||||
|   let relativeTime; | ||||
|  | @ -74,16 +79,16 @@ export const timeAgoString = (intl, date, now, year, timeGiven = true) => { | |||
|   if (delta < DAY && !timeGiven) { | ||||
|     relativeTime = intl.formatMessage(messages.today); | ||||
|   } else if (delta < 10 * SECOND) { | ||||
|     relativeTime = intl.formatMessage(messages.just_now); | ||||
|     relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full); | ||||
|   } else if (delta < 7 * DAY) { | ||||
|     if (delta < MINUTE) { | ||||
|       relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); | ||||
|       relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) }); | ||||
|     } else if (delta < HOUR) { | ||||
|       relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); | ||||
|       relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) }); | ||||
|     } else if (delta < DAY) { | ||||
|       relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); | ||||
|       relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) }); | ||||
|     } else { | ||||
|       relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); | ||||
|       relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) }); | ||||
|     } | ||||
|   } else if (date.getFullYear() === year) { | ||||
|     relativeTime = intl.formatDate(date, shortDateFormatOptions); | ||||
|  | @ -124,6 +129,7 @@ class RelativeTimestamp extends React.Component { | |||
|     timestamp: PropTypes.string.isRequired, | ||||
|     year: PropTypes.number.isRequired, | ||||
|     futureDate: PropTypes.bool, | ||||
|     short: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -132,6 +138,7 @@ class RelativeTimestamp extends React.Component { | |||
| 
 | ||||
|   static defaultProps = { | ||||
|     year: (new Date()).getFullYear(), | ||||
|     short: true, | ||||
|   }; | ||||
| 
 | ||||
|   shouldComponentUpdate (nextProps, nextState) { | ||||
|  | @ -176,11 +183,11 @@ class RelativeTimestamp extends React.Component { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { timestamp, intl, year, futureDate } = this.props; | ||||
|     const { timestamp, intl, year, futureDate, short } = this.props; | ||||
| 
 | ||||
|     const timeGiven    = timestamp.includes('T'); | ||||
|     const date         = new Date(timestamp); | ||||
|     const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven); | ||||
|     const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short); | ||||
| 
 | ||||
|     return ( | ||||
|       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name'; | |||
| import StatusContent from '../../../components/status_content'; | ||||
| import MediaGallery from '../../../components/media_gallery'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl'; | ||||
| import { injectIntl, defineMessages, FormattedDate } from 'react-intl'; | ||||
| import Card from './card'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import Video from '../../video'; | ||||
|  | @ -16,6 +16,7 @@ import classNames from 'classnames'; | |||
| import Icon from 'mastodon/components/icon'; | ||||
| import AnimatedNumber from 'mastodon/components/animated_number'; | ||||
| import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; | ||||
| import EditedTimestamp from 'mastodon/components/edited_timestamp'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, | ||||
|  | @ -242,7 +243,7 @@ class DetailedStatus extends ImmutablePureComponent { | |||
|       edited = ( | ||||
|         <React.Fragment> | ||||
|           <React.Fragment> · </React.Fragment> | ||||
|           <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> | ||||
|           <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /> | ||||
|         </React.Fragment> | ||||
|       ); | ||||
|     } | ||||
|  |  | |||
|  | @ -0,0 +1,79 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import { closeModal } from 'mastodon/actions/modal'; | ||||
| import emojify from 'mastodon/features/emoji/emoji'; | ||||
| import escapeTextContentForBrowser from 'escape-html'; | ||||
| import InlineAccount from 'mastodon/components/inline_account'; | ||||
| import IconButton from 'mastodon/components/icon_button'; | ||||
| import RelativeTimestamp from 'mastodon/components/relative_timestamp'; | ||||
| 
 | ||||
| const mapStateToProps = (state, { statusId }) => ({ | ||||
|   versions: state.getIn(['history', statusId, 'items']), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
| 
 | ||||
|   onClose() { | ||||
|     dispatch(closeModal()); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default @connect(mapStateToProps, mapDispatchToProps) | ||||
| class CompareHistoryModal extends React.PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     index: PropTypes.number.isRequired, | ||||
|     statusId: PropTypes.string.isRequired, | ||||
|     versions: ImmutablePropTypes.list.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { index, versions, onClose } = this.props; | ||||
|     const currentVersion = versions.get(index); | ||||
| 
 | ||||
|     const emojiMap = currentVersion.get('emojis').reduce((obj, emoji) => { | ||||
|       obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); | ||||
|       return obj; | ||||
|     }, {}); | ||||
| 
 | ||||
|     const content = { __html: emojify(currentVersion.get('content'), emojiMap) }; | ||||
|     const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) }; | ||||
| 
 | ||||
|     const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />; | ||||
|     const formattedName = <InlineAccount accountId={currentVersion.get('account')} />; | ||||
| 
 | ||||
|     const label = currentVersion.get('original') ? ( | ||||
|       <FormattedMessage id='status.history.created' defaultMessage='{name} created {date}' values={{ name: formattedName, date: formattedDate }} /> | ||||
|     ) : ( | ||||
|       <FormattedMessage id='status.history.edited' defaultMessage='{name} edited {date}' values={{ name: formattedName, date: formattedDate }} /> | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='modal-root__modal compare-history-modal'> | ||||
|         <div className='report-modal__target'> | ||||
|           <IconButton className='report-modal__close' icon='times' onClick={onClose} size={20} /> | ||||
|           {label} | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='compare-history-modal__container'> | ||||
|           <div className='status__content'> | ||||
|             {currentVersion.get('spoiler_text').length > 0 && ( | ||||
|               <React.Fragment> | ||||
|                 <div className='translate' dangerouslySetInnerHTML={spoilerContent} /> | ||||
|                 <hr /> | ||||
|               </React.Fragment> | ||||
|             )} | ||||
| 
 | ||||
|             <div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -19,7 +19,8 @@ import { | |||
|   EmbedModal, | ||||
|   ListEditor, | ||||
|   ListAdder, | ||||
| } from '../../../features/ui/util/async-components'; | ||||
|   CompareHistoryModal, | ||||
| } from 'mastodon/features/ui/util/async-components'; | ||||
| 
 | ||||
| const MODAL_COMPONENTS = { | ||||
|   'MEDIA': () => Promise.resolve({ default: MediaModal }), | ||||
|  | @ -34,7 +35,8 @@ const MODAL_COMPONENTS = { | |||
|   'EMBED': EmbedModal, | ||||
|   'LIST_EDITOR': ListEditor, | ||||
|   'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), | ||||
|   'LIST_ADDER':ListAdder, | ||||
|   'LIST_ADDER': ListAdder, | ||||
|   'COMPARE_HISTORY': CompareHistoryModal, | ||||
| }; | ||||
| 
 | ||||
| export default class ModalRoot extends React.PureComponent { | ||||
|  |  | |||
|  | @ -157,3 +157,7 @@ export function Directory () { | |||
| export function FollowRecommendations () { | ||||
|   return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations'); | ||||
| } | ||||
| 
 | ||||
| export function CompareHistoryModal () { | ||||
|   return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal'); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										28
									
								
								app/javascript/mastodon/reducers/history.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/javascript/mastodon/reducers/history.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import { HISTORY_FETCH_REQUEST, HISTORY_FETCH_SUCCESS, HISTORY_FETCH_FAIL } from 'mastodon/actions/history'; | ||||
| import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; | ||||
| 
 | ||||
| const initialHistory = ImmutableMap({ | ||||
|   loading: false, | ||||
|   items: ImmutableList(), | ||||
| }); | ||||
| 
 | ||||
| const initialState = ImmutableMap(); | ||||
| 
 | ||||
| export default function history(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case HISTORY_FETCH_REQUEST: | ||||
|     return state.update(action.statusId, initialHistory, history => history.withMutations(map => { | ||||
|       map.set('loading', true); | ||||
|       map.set('items', ImmutableList()); | ||||
|     })); | ||||
|   case HISTORY_FETCH_SUCCESS: | ||||
|     return state.update(action.statusId, initialHistory, history => history.withMutations(map => { | ||||
|       map.set('loading', false); | ||||
|       map.set('items', fromJS(action.history.map((x, i) => ({ ...x, account: x.account.id, original: i === 0 })).reverse())); | ||||
|     })); | ||||
|   case HISTORY_FETCH_FAIL: | ||||
|     return state.update(action.statusId, initialHistory, history => history.set('loading', false)); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
| } | ||||
|  | @ -38,6 +38,7 @@ import announcements from './announcements'; | |||
| import markers from './markers'; | ||||
| import picture_in_picture from './picture_in_picture'; | ||||
| import accounts_map from './accounts_map'; | ||||
| import history from './history'; | ||||
| 
 | ||||
| const reducers = { | ||||
|   announcements, | ||||
|  | @ -79,6 +80,7 @@ const reducers = { | |||
|   missed_updates, | ||||
|   markers, | ||||
|   picture_in_picture, | ||||
|   history, | ||||
| }; | ||||
| 
 | ||||
| export default combineReducers(reducers); | ||||
|  |  | |||
|  | @ -1889,8 +1889,47 @@ a.account__display-name { | |||
|   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); | ||||
|   z-index: 9999; | ||||
| 
 | ||||
|   ul { | ||||
|     list-style: none; | ||||
|   &__text-button { | ||||
|     display: inline; | ||||
|     color: inherit; | ||||
|     background: transparent; | ||||
|     border: 0; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     font-family: inherit; | ||||
|     font-size: inherit; | ||||
|     line-height: inherit; | ||||
| 
 | ||||
|     &:focus { | ||||
|       outline: 1px dotted; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &__container { | ||||
|     &__header { | ||||
|       border-bottom: 1px solid darken($ui-secondary-color, 8%); | ||||
|       padding: 4px 14px; | ||||
|       padding-bottom: 8px; | ||||
|       font-size: 13px; | ||||
|       line-height: 18px; | ||||
|       color: $inverted-text-color; | ||||
|     } | ||||
| 
 | ||||
|     &__list { | ||||
|       list-style: none; | ||||
| 
 | ||||
|       &--scrollable { | ||||
|         max-height: 300px; | ||||
|         overflow-y: scroll; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     &--loading { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       justify-content: center; | ||||
|       padding: 30px 45px; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.left { | ||||
|  | @ -1946,18 +1985,29 @@ a.account__display-name { | |||
| } | ||||
| 
 | ||||
| .dropdown-menu__item { | ||||
|   a { | ||||
|     font-size: 13px; | ||||
|     line-height: 18px; | ||||
|   font-size: 13px; | ||||
|   line-height: 18px; | ||||
|   display: block; | ||||
|   color: $inverted-text-color; | ||||
| 
 | ||||
|   a, | ||||
|   button { | ||||
|     font-family: inherit; | ||||
|     font-size: inherit; | ||||
|     line-height: inherit; | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     padding: 4px 14px; | ||||
|     border: 0; | ||||
|     margin: 0; | ||||
|     box-sizing: border-box; | ||||
|     text-decoration: none; | ||||
|     background: $ui-secondary-color; | ||||
|     color: $inverted-text-color; | ||||
|     color: inherit; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
|     text-align: inherit; | ||||
| 
 | ||||
|     &:focus, | ||||
|     &:hover, | ||||
|  | @ -1969,6 +2019,42 @@ a.account__display-name { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .dropdown-menu__item--text { | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   padding: 4px 14px; | ||||
| } | ||||
| 
 | ||||
| .dropdown-menu__item.edited-timestamp__history__item { | ||||
|   border-bottom: 1px solid darken($ui-secondary-color, 8%); | ||||
| 
 | ||||
|   &:last-child { | ||||
|     border-bottom: 0; | ||||
|   } | ||||
| 
 | ||||
|   &.dropdown-menu__item--text, | ||||
|   a, | ||||
|   button { | ||||
|     padding: 8px 14px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .inline-account { | ||||
|   display: inline-flex; | ||||
|   align-items: center; | ||||
|   vertical-align: top; | ||||
| 
 | ||||
|   .account__avatar { | ||||
|     margin-right: 5px; | ||||
|     border-radius: 50%; | ||||
|   } | ||||
| 
 | ||||
|   strong { | ||||
|     font-weight: 600; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .dropdown--active .dropdown__content { | ||||
|   display: block; | ||||
|   line-height: 18px; | ||||
|  | @ -3631,36 +3717,48 @@ a.status-card.compact:hover { | |||
|   top: 50%; | ||||
|   left: 50%; | ||||
|   transform: translate(-50%, -50%); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| } | ||||
| 
 | ||||
|   span { | ||||
|     display: block; | ||||
|     float: left; | ||||
|     transform: translateX(-50%); | ||||
|     margin: 82px 0 0 50%; | ||||
|     white-space: nowrap; | ||||
| .circular-progress { | ||||
|   color: lighten($ui-base-color, 26%); | ||||
|   animation: 1.4s linear 0s infinite normal none running simple-rotate; | ||||
| 
 | ||||
|   circle { | ||||
|     stroke: currentColor; | ||||
|     stroke-dasharray: 80px, 200px; | ||||
|     stroke-dashoffset: 0; | ||||
|     animation: circular-progress 1.4s ease-in-out infinite; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .loading-indicator__figure { | ||||
|   position: absolute; | ||||
|   top: 50%; | ||||
|   left: 50%; | ||||
|   transform: translate(-50%, -50%); | ||||
|   width: 42px; | ||||
|   height: 42px; | ||||
|   box-sizing: border-box; | ||||
|   background-color: transparent; | ||||
|   border: 0 solid lighten($ui-base-color, 26%); | ||||
|   border-width: 6px; | ||||
|   border-radius: 50%; | ||||
| @keyframes circular-progress { | ||||
|   0% { | ||||
|     stroke-dasharray: 1px, 200px; | ||||
|     stroke-dashoffset: 0; | ||||
|   } | ||||
| 
 | ||||
|   50% { | ||||
|     stroke-dasharray: 100px, 200px; | ||||
|     stroke-dashoffset: -15px; | ||||
|   } | ||||
| 
 | ||||
|   100% { | ||||
|     stroke-dasharray: 100px, 200px; | ||||
|     stroke-dashoffset: -125px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .no-reduce-motion .loading-indicator span { | ||||
|   animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1); | ||||
| } | ||||
| @keyframes simple-rotate { | ||||
|   0% { | ||||
|     transform: rotate(0deg); | ||||
|   } | ||||
| 
 | ||||
| .no-reduce-motion .loading-indicator__figure { | ||||
|   animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1); | ||||
|   100% { | ||||
|     transform: rotate(360deg); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @keyframes spring-rotate-in { | ||||
|  | @ -3707,40 +3805,6 @@ a.status-card.compact:hover { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| @keyframes loader-figure { | ||||
|   0% { | ||||
|     width: 0; | ||||
|     height: 0; | ||||
|     background-color: lighten($ui-base-color, 26%); | ||||
|   } | ||||
| 
 | ||||
|   29% { | ||||
|     background-color: lighten($ui-base-color, 26%); | ||||
|   } | ||||
| 
 | ||||
|   30% { | ||||
|     width: 42px; | ||||
|     height: 42px; | ||||
|     background-color: transparent; | ||||
|     border-width: 21px; | ||||
|     opacity: 1; | ||||
|   } | ||||
| 
 | ||||
|   100% { | ||||
|     width: 42px; | ||||
|     height: 42px; | ||||
|     border-width: 0; | ||||
|     opacity: 0; | ||||
|     background-color: transparent; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @keyframes loader-label { | ||||
|   0% { opacity: 0.25; } | ||||
|   30% { opacity: 1; } | ||||
|   100% { opacity: 0.25; } | ||||
| } | ||||
| 
 | ||||
| .video-error-cover { | ||||
|   align-items: center; | ||||
|   background: $base-overlay-background; | ||||
|  | @ -4940,7 +5004,8 @@ a.status-card.compact:hover { | |||
| .report-modal, | ||||
| .actions-modal, | ||||
| .mute-modal, | ||||
| .block-modal { | ||||
| .block-modal, | ||||
| .compare-history-modal { | ||||
|   background: lighten($ui-secondary-color, 8%); | ||||
|   color: $inverted-text-color; | ||||
|   border-radius: 8px; | ||||
|  | @ -5342,6 +5407,41 @@ a.status-card.compact:hover { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .compare-history-modal { | ||||
|   .report-modal__target { | ||||
|     border-bottom: 1px solid $ui-secondary-color; | ||||
|   } | ||||
| 
 | ||||
|   &__container { | ||||
|     padding: 30px; | ||||
|     pointer-events: all; | ||||
|   } | ||||
| 
 | ||||
|   .status__content { | ||||
|     color: $inverted-text-color; | ||||
|     font-size: 19px; | ||||
|     line-height: 24px; | ||||
| 
 | ||||
|     .emojione { | ||||
|       width: 24px; | ||||
|       height: 24px; | ||||
|       margin: -1px 0 0; | ||||
|     } | ||||
| 
 | ||||
|     a { | ||||
|       color: $highlight-text-color; | ||||
|     } | ||||
| 
 | ||||
|     hr { | ||||
|       height: 0.25rem; | ||||
|       padding: 0; | ||||
|       background-color: $ui-secondary-color; | ||||
|       border: 0; | ||||
|       margin: 20px 0; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .loading-bar { | ||||
|   background-color: $highlight-text-color; | ||||
|   height: 3px; | ||||
|  |  | |||
|  | @ -20,6 +20,6 @@ class Admin::Metrics::Dimension::LanguagesDimension < Admin::Metrics::Dimension: | |||
| 
 | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, @start_at], [nil, @end_at], [nil, @limit]]) | ||||
| 
 | ||||
|     rows.map { |row| { key: row['locale'], human_key: human_locale(row['locale']), value: row['value'].to_s } } | ||||
|     rows.map { |row| { key: row['locale'], human_key: standard_locale_name(row['locale']), value: row['value'].to_s } } | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ class Admin::Metrics::Dimension::TagLanguagesDimension < Admin::Metrics::Dimensi | |||
| 
 | ||||
|     rows = ActiveRecord::Base.connection.select_all(sql, nil, [[nil, params[:id]], [nil, Mastodon::Snowflake.id_at(@start_at, with_random: false)], [nil, Mastodon::Snowflake.id_at(@end_at, with_random: false)], [nil, @limit]]) | ||||
| 
 | ||||
|     rows.map { |row| { key: row['language'], human_key: human_locale(row['language']), value: row['value'].to_s } } | ||||
|     rows.map { |row| { key: row['language'], human_key: standard_locale_name(row['language']), value: row['value'].to_s } } | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
|  |  | |||
|  | @ -32,7 +32,7 @@ class Formatter | |||
|   include ActionView::Helpers::TextHelper | ||||
| 
 | ||||
|   def format(status, **options) | ||||
|     if status.reblog? | ||||
|     if status.respond_to?(:reblog?) && status.reblog? | ||||
|       prepend_reblog = status.reblog.account.acct | ||||
|       status         = status.proper | ||||
|     else | ||||
|  | @ -53,7 +53,7 @@ class Formatter | |||
|       return html.html_safe # rubocop:disable Rails/OutputSafety | ||||
|     end | ||||
| 
 | ||||
|     linkable_accounts = status.active_mentions.map(&:account) | ||||
|     linkable_accounts = status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [] | ||||
|     linkable_accounts << status.account | ||||
| 
 | ||||
|     html = raw_content | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ class AccountSuggestions::GlobalSource < AccountSuggestions::Source | |||
|   end | ||||
| 
 | ||||
|   def get(account, skip_account_ids: [], limit: 40) | ||||
|     account_ids = account_ids_for_locale(I18n.locale.to_str.split(/[_-]/).first) - [account.id] - skip_account_ids | ||||
|     account_ids = account_ids_for_locale(I18n.locale.to_s.split(/[_-]/).first) - [account.id] - skip_account_ids | ||||
| 
 | ||||
|     as_ordered_suggestions( | ||||
|       scope(account).where(id: account_ids), | ||||
|  |  | |||
|  | @ -20,4 +20,9 @@ class StatusEdit < ApplicationRecord | |||
|   default_scope { order(id: :asc) } | ||||
| 
 | ||||
|   delegate :local?, to: :status | ||||
| 
 | ||||
|   def emojis | ||||
|     return @emojis if defined?(@emojis) | ||||
|     @emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -1,6 +1,14 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class REST::StatusEditSerializer < ActiveModel::Serializer | ||||
|   attributes :text, :spoiler_text, :media_attachments_changed, | ||||
|              :created_at | ||||
|   has_one :account, serializer: REST::AccountSerializer | ||||
| 
 | ||||
|   attributes :content, :spoiler_text, | ||||
|              :media_attachments_changed, :created_at | ||||
| 
 | ||||
|   has_many :emojis, serializer: REST::CustomEmojiSerializer | ||||
| 
 | ||||
|   def content | ||||
|     Formatter.instance.format(object) | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -147,7 +147,7 @@ | |||
| 
 | ||||
|           %tr | ||||
|             %th= t('simple_form.labels.defaults.locale') | ||||
|             %td= @account.user_locale | ||||
|             %td= standard_locale_name(@account.user_locale) | ||||
|             %td | ||||
| 
 | ||||
|           %tr | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ | |||
|     .filter-subset.filter-subset--with-select | ||||
|       %strong= t('admin.follow_recommendations.language') | ||||
|       .input.select.optional | ||||
|         = select_tag :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [human_locale(key), key]}, @language) | ||||
|         = select_tag :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [standard_locale_name(key), key]}, @language) | ||||
| 
 | ||||
|     .filter-subset | ||||
|       %strong= t('admin.follow_recommendations.status') | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ | |||
|         • | ||||
| 
 | ||||
|       - if preview_card.language.present? | ||||
|         = human_locale(preview_card.language) | ||||
|         = standard_locale_name(preview_card.language) | ||||
|         • | ||||
| 
 | ||||
|       = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts }) | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ | |||
| 
 | ||||
| = simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f| | ||||
|   .fields-group | ||||
|     = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale, hint: false | ||||
|     = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, selected: I18n.locale, hint: false | ||||
| 
 | ||||
|   - unless I18n.locale == :en | ||||
|     .flash-message.translation-prompt | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ | |||
|       = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), I18n.t("statuses.visibilities.#{visibility}_long")], ' - ') }, required: false, hint: false | ||||
| 
 | ||||
|     .fields-group.fields-row__column.fields-row__column-6 | ||||
|       = f.input :setting_default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : human_locale(locale) }, required: false, include_blank: false, hint: false | ||||
|       = f.input :setting_default_language, collection: [nil] + filterable_languages, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.default_language') : native_locale_name(locale) }, required: false, include_blank: false, hint: false | ||||
| 
 | ||||
|   .fields-group | ||||
|     = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label | ||||
|  | @ -41,7 +41,7 @@ | |||
|   %h4= t 'preferences.public_timelines' | ||||
| 
 | ||||
|   .fields-group | ||||
|     = f.input :chosen_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' | ||||
|     = f.input :chosen_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' | ||||
| 
 | ||||
|   .actions | ||||
|     = f.button :button, t('generic.save_changes'), type: :submit | ||||
|  |  | |||
|  | @ -9,9 +9,15 @@ describe LanguagesHelper do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'human_locale' do | ||||
|     it 'finds the human readable local description from a key' do | ||||
|       expect(helper.human_locale(:en)).to eq('English') | ||||
|   describe 'native_locale_name' do | ||||
|     it 'finds the human readable native name from a key' do | ||||
|       expect(helper.native_locale_name(:en)).to eq('English') | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'standard_locale_name' do | ||||
|     it 'finds the human readable standard name from a key' do | ||||
|       expect(helper.standard_locale_name(:de)).to eq('German') | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue