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 |   before_action :set_status | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|     render json: @status.edits, each_serializer: REST::StatusEditSerializer |     render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  | @ -209,8 +209,8 @@ module LanguagesHelper | ||||||
|     'zh-TW': '繁體中文(臺灣)', |     'zh-TW': '繁體中文(臺灣)', | ||||||
|   }.freeze |   }.freeze | ||||||
| 
 | 
 | ||||||
|   def human_locale(locale) |   def native_locale_name(locale) | ||||||
|     if locale == 'und' |     if locale.blank? || locale == 'und' | ||||||
|       I18n.t('generic.none') |       I18n.t('generic.none') | ||||||
|     elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) |     elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym]) | ||||||
|       supported_locale[1] |       supported_locale[1] | ||||||
|  | @ -221,6 +221,16 @@ module LanguagesHelper | ||||||
|     end |     end | ||||||
|   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) |   def valid_locale_or_nil(str) | ||||||
|     return if str.blank? |     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 Motion from '../features/ui/util/optional_motion'; | ||||||
| import spring from 'react-motion/lib/spring'; | import spring from 'react-motion/lib/spring'; | ||||||
| import { supportsPassiveEvents } from 'detect-passive-events'; | import { supportsPassiveEvents } from 'detect-passive-events'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  | import { CircularProgress } from 'mastodon/components/loading_indicator'; | ||||||
| 
 | 
 | ||||||
| const listenerOptions = supportsPassiveEvents ? { passive: true } : false; | const listenerOptions = supportsPassiveEvents ? { passive: true } : false; | ||||||
| let id = 0; | let id = 0; | ||||||
|  | @ -17,13 +19,18 @@ class DropdownMenu extends React.PureComponent { | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     items: PropTypes.array.isRequired, |     items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, | ||||||
|  |     loading: PropTypes.bool, | ||||||
|  |     scrollable: PropTypes.bool, | ||||||
|     onClose: PropTypes.func.isRequired, |     onClose: PropTypes.func.isRequired, | ||||||
|     style: PropTypes.object, |     style: PropTypes.object, | ||||||
|     placement: PropTypes.string, |     placement: PropTypes.string, | ||||||
|     arrowOffsetLeft: PropTypes.string, |     arrowOffsetLeft: PropTypes.string, | ||||||
|     arrowOffsetTop: PropTypes.string, |     arrowOffsetTop: PropTypes.string, | ||||||
|     openedViaKeyboard: PropTypes.bool, |     openedViaKeyboard: PropTypes.bool, | ||||||
|  |     renderItem: PropTypes.func, | ||||||
|  |     renderHeader: PropTypes.func, | ||||||
|  |     onItemClick: PropTypes.func.isRequired, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|  | @ -45,9 +52,11 @@ class DropdownMenu extends React.PureComponent { | ||||||
|     document.addEventListener('click', this.handleDocumentClick, false); |     document.addEventListener('click', this.handleDocumentClick, false); | ||||||
|     document.addEventListener('keydown', this.handleKeyDown, false); |     document.addEventListener('keydown', this.handleKeyDown, false); | ||||||
|     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); |     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||||
|  | 
 | ||||||
|     if (this.focusedItem && this.props.openedViaKeyboard) { |     if (this.focusedItem && this.props.openedViaKeyboard) { | ||||||
|       this.focusedItem.focus({ preventScroll: true }); |       this.focusedItem.focus({ preventScroll: true }); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     this.setState({ mounted: true }); |     this.setState({ mounted: true }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -66,7 +75,7 @@ class DropdownMenu extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleKeyDown = e => { |   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); |     const index = items.indexOf(document.activeElement); | ||||||
|     let element = null; |     let element = null; | ||||||
| 
 | 
 | ||||||
|  | @ -109,21 +118,11 @@ class DropdownMenu extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleClick = e => { |   handleClick = e => { | ||||||
|     const i = Number(e.currentTarget.getAttribute('data-index')); |     const { onItemClick } = this.props; | ||||||
|     const { action, to } = this.props.items[i]; |     onItemClick(e); | ||||||
| 
 |  | ||||||
|     this.props.onClose(); |  | ||||||
| 
 |  | ||||||
|     if (typeof action === 'function') { |  | ||||||
|       e.preventDefault(); |  | ||||||
|       action(e); |  | ||||||
|     } else if (to) { |  | ||||||
|       e.preventDefault(); |  | ||||||
|       this.context.router.history.push(to); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   renderItem (option, i) { |   renderItem = (option, i) => { | ||||||
|     if (option === null) { |     if (option === null) { | ||||||
|       return <li key={`sep-${i}`} className='dropdown-menu__separator' />; |       return <li key={`sep-${i}`} className='dropdown-menu__separator' />; | ||||||
|     } |     } | ||||||
|  | @ -140,9 +139,11 @@ class DropdownMenu extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; |     const { items, style, placement, arrowOffsetLeft, arrowOffsetTop, scrollable, renderHeader, loading } = this.props; | ||||||
|     const { mounted } = this.state; |     const { mounted } = this.state; | ||||||
| 
 | 
 | ||||||
|  |     let renderItem = this.props.renderItem || this.renderItem; | ||||||
|  | 
 | ||||||
|     return ( |     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 }) }}> |       <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 }) => ( |         {({ 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 ${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 }} /> |             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> | ||||||
| 
 | 
 | ||||||
|             <ul> |             <div className={classNames('dropdown-menu__container', { 'dropdown-menu__container--loading': loading })}> | ||||||
|               {items.map((option, i) => this.renderItem(option, i))} |               {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> |                 </ul> | ||||||
|  |               )} | ||||||
|  |             </div> | ||||||
|           </div> |           </div> | ||||||
|         )} |         )} | ||||||
|       </Motion> |       </Motion> | ||||||
|  | @ -170,11 +185,14 @@ export default class Dropdown extends React.PureComponent { | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     icon: PropTypes.string.isRequired, |     children: PropTypes.node, | ||||||
|     items: PropTypes.array.isRequired, |     icon: PropTypes.string, | ||||||
|     size: PropTypes.number.isRequired, |     items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, | ||||||
|  |     loading: PropTypes.bool, | ||||||
|  |     size: PropTypes.number, | ||||||
|     title: PropTypes.string, |     title: PropTypes.string, | ||||||
|     disabled: PropTypes.bool, |     disabled: PropTypes.bool, | ||||||
|  |     scrollable: PropTypes.bool, | ||||||
|     status: ImmutablePropTypes.map, |     status: ImmutablePropTypes.map, | ||||||
|     isUserTouching: PropTypes.func, |     isUserTouching: PropTypes.func, | ||||||
|     onOpen: PropTypes.func.isRequired, |     onOpen: PropTypes.func.isRequired, | ||||||
|  | @ -182,6 +200,9 @@ export default class Dropdown extends React.PureComponent { | ||||||
|     dropdownPlacement: PropTypes.string, |     dropdownPlacement: PropTypes.string, | ||||||
|     openDropdownId: PropTypes.number, |     openDropdownId: PropTypes.number, | ||||||
|     openedViaKeyboard: PropTypes.bool, |     openedViaKeyboard: PropTypes.bool, | ||||||
|  |     renderItem: PropTypes.func, | ||||||
|  |     renderHeader: PropTypes.func, | ||||||
|  |     onItemClick: PropTypes.func, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|  | @ -237,17 +258,21 @@ export default class Dropdown extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleItemClick = e => { |   handleItemClick = e => { | ||||||
|  |     const { onItemClick } = this.props; | ||||||
|     const i = Number(e.currentTarget.getAttribute('data-index')); |     const i = Number(e.currentTarget.getAttribute('data-index')); | ||||||
|     const { action, to } = this.props.items[i]; |     const item = this.props.items[i]; | ||||||
| 
 | 
 | ||||||
|     this.handleClose(); |     this.handleClose(); | ||||||
| 
 | 
 | ||||||
|     if (typeof action === 'function') { |     if (typeof onItemClick === 'function') { | ||||||
|       e.preventDefault(); |       e.preventDefault(); | ||||||
|       action(); |       onItemClick(item, i); | ||||||
|     } else if (to) { |     } else if (item && typeof item.action === 'function') { | ||||||
|       e.preventDefault(); |       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,12 +290,36 @@ export default class Dropdown extends React.PureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   close = () => { | ||||||
|  |     this.handleClose(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   render () { |   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 open = this.state.id === openDropdownId; | ||||||
| 
 | 
 | ||||||
|     return ( |     const button = children ? React.cloneElement(React.Children.only(children), { | ||||||
|       <div> |       ref: this.setTargetRef, | ||||||
|  |       onClick: this.handleClick, | ||||||
|  |       onMouseDown: this.handleMouseDown, | ||||||
|  |       onKeyDown: this.handleButtonKeyDown, | ||||||
|  |       onKeyPress: this.handleKeyPress, | ||||||
|  |     }) : ( | ||||||
|       <IconButton |       <IconButton | ||||||
|         icon={icon} |         icon={icon} | ||||||
|         title={title} |         title={title} | ||||||
|  | @ -283,11 +332,25 @@ export default class Dropdown extends React.PureComponent { | ||||||
|         onKeyDown={this.handleButtonKeyDown} |         onKeyDown={this.handleButtonKeyDown} | ||||||
|         onKeyPress={this.handleKeyPress} |         onKeyPress={this.handleKeyPress} | ||||||
|       /> |       /> | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <React.Fragment> | ||||||
|  |         {button} | ||||||
| 
 | 
 | ||||||
|         <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> |         <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> |         </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 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 = () => ( | const LoadingIndicator = () => ( | ||||||
|   <div className='loading-indicator'> |   <div className='loading-indicator'> | ||||||
|     <div className='loading-indicator__figure' /> |     <CircularProgress size={50} strokeWidth={6} /> | ||||||
|     <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> |  | ||||||
|   </div> |   </div> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,10 +5,15 @@ import PropTypes from 'prop-types'; | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   today: { id: 'relative_time.today', defaultMessage: 'today' }, |   today: { id: 'relative_time.today', defaultMessage: 'today' }, | ||||||
|   just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, |   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: { 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: { 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: { 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: { 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' }, |   moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, | ||||||
|   seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, |   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' }, |   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(); |   const delta = now - date.getTime(); | ||||||
| 
 | 
 | ||||||
|   let relativeTime; |   let relativeTime; | ||||||
|  | @ -74,16 +79,16 @@ export const timeAgoString = (intl, date, now, year, timeGiven = true) => { | ||||||
|   if (delta < DAY && !timeGiven) { |   if (delta < DAY && !timeGiven) { | ||||||
|     relativeTime = intl.formatMessage(messages.today); |     relativeTime = intl.formatMessage(messages.today); | ||||||
|   } else if (delta < 10 * SECOND) { |   } 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) { |   } else if (delta < 7 * DAY) { | ||||||
|     if (delta < MINUTE) { |     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) { |     } 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) { |     } 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 { |     } 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) { |   } else if (date.getFullYear() === year) { | ||||||
|     relativeTime = intl.formatDate(date, shortDateFormatOptions); |     relativeTime = intl.formatDate(date, shortDateFormatOptions); | ||||||
|  | @ -124,6 +129,7 @@ class RelativeTimestamp extends React.Component { | ||||||
|     timestamp: PropTypes.string.isRequired, |     timestamp: PropTypes.string.isRequired, | ||||||
|     year: PropTypes.number.isRequired, |     year: PropTypes.number.isRequired, | ||||||
|     futureDate: PropTypes.bool, |     futureDate: PropTypes.bool, | ||||||
|  |     short: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -132,6 +138,7 @@ class RelativeTimestamp extends React.Component { | ||||||
| 
 | 
 | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|     year: (new Date()).getFullYear(), |     year: (new Date()).getFullYear(), | ||||||
|  |     short: true, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   shouldComponentUpdate (nextProps, nextState) { |   shouldComponentUpdate (nextProps, nextState) { | ||||||
|  | @ -176,11 +183,11 @@ class RelativeTimestamp extends React.Component { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { timestamp, intl, year, futureDate } = this.props; |     const { timestamp, intl, year, futureDate, short } = this.props; | ||||||
| 
 | 
 | ||||||
|     const timeGiven    = timestamp.includes('T'); |     const timeGiven    = timestamp.includes('T'); | ||||||
|     const date         = new Date(timestamp); |     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 ( |     return ( | ||||||
|       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> |       <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 StatusContent from '../../../components/status_content'; | ||||||
| import MediaGallery from '../../../components/media_gallery'; | import MediaGallery from '../../../components/media_gallery'; | ||||||
| import { Link } from 'react-router-dom'; | 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 Card from './card'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import Video from '../../video'; | import Video from '../../video'; | ||||||
|  | @ -16,6 +16,7 @@ import classNames from 'classnames'; | ||||||
| import Icon from 'mastodon/components/icon'; | import Icon from 'mastodon/components/icon'; | ||||||
| import AnimatedNumber from 'mastodon/components/animated_number'; | import AnimatedNumber from 'mastodon/components/animated_number'; | ||||||
| import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; | import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; | ||||||
|  | import EditedTimestamp from 'mastodon/components/edited_timestamp'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, |   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, | ||||||
|  | @ -242,7 +243,7 @@ class DetailedStatus extends ImmutablePureComponent { | ||||||
|       edited = ( |       edited = ( | ||||||
|         <React.Fragment> |         <React.Fragment> | ||||||
|           <React.Fragment> · </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> |         </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, |   EmbedModal, | ||||||
|   ListEditor, |   ListEditor, | ||||||
|   ListAdder, |   ListAdder, | ||||||
| } from '../../../features/ui/util/async-components'; |   CompareHistoryModal, | ||||||
|  | } from 'mastodon/features/ui/util/async-components'; | ||||||
| 
 | 
 | ||||||
| const MODAL_COMPONENTS = { | const MODAL_COMPONENTS = { | ||||||
|   'MEDIA': () => Promise.resolve({ default: MediaModal }), |   'MEDIA': () => Promise.resolve({ default: MediaModal }), | ||||||
|  | @ -34,7 +35,8 @@ const MODAL_COMPONENTS = { | ||||||
|   'EMBED': EmbedModal, |   'EMBED': EmbedModal, | ||||||
|   'LIST_EDITOR': ListEditor, |   'LIST_EDITOR': ListEditor, | ||||||
|   'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), |   'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), | ||||||
|   'LIST_ADDER':ListAdder, |   'LIST_ADDER': ListAdder, | ||||||
|  |   'COMPARE_HISTORY': CompareHistoryModal, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default class ModalRoot extends React.PureComponent { | export default class ModalRoot extends React.PureComponent { | ||||||
|  |  | ||||||
|  | @ -157,3 +157,7 @@ export function Directory () { | ||||||
| export function FollowRecommendations () { | export function FollowRecommendations () { | ||||||
|   return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations'); |   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 markers from './markers'; | ||||||
| import picture_in_picture from './picture_in_picture'; | import picture_in_picture from './picture_in_picture'; | ||||||
| import accounts_map from './accounts_map'; | import accounts_map from './accounts_map'; | ||||||
|  | import history from './history'; | ||||||
| 
 | 
 | ||||||
| const reducers = { | const reducers = { | ||||||
|   announcements, |   announcements, | ||||||
|  | @ -79,6 +80,7 @@ const reducers = { | ||||||
|   missed_updates, |   missed_updates, | ||||||
|   markers, |   markers, | ||||||
|   picture_in_picture, |   picture_in_picture, | ||||||
|  |   history, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default combineReducers(reducers); | export default combineReducers(reducers); | ||||||
|  |  | ||||||
|  | @ -1889,8 +1889,47 @@ a.account__display-name { | ||||||
|   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); |   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); | ||||||
|   z-index: 9999; |   z-index: 9999; | ||||||
| 
 | 
 | ||||||
|   ul { |   &__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; |       list-style: none; | ||||||
|  | 
 | ||||||
|  |       &--scrollable { | ||||||
|  |         max-height: 300px; | ||||||
|  |         overflow-y: scroll; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     &--loading { | ||||||
|  |       display: flex; | ||||||
|  |       align-items: center; | ||||||
|  |       justify-content: center; | ||||||
|  |       padding: 30px 45px; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   &.left { |   &.left { | ||||||
|  | @ -1946,18 +1985,29 @@ a.account__display-name { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .dropdown-menu__item { | .dropdown-menu__item { | ||||||
|   a { |  | ||||||
|   font-size: 13px; |   font-size: 13px; | ||||||
|   line-height: 18px; |   line-height: 18px; | ||||||
|   display: block; |   display: block; | ||||||
|  |   color: $inverted-text-color; | ||||||
|  | 
 | ||||||
|  |   a, | ||||||
|  |   button { | ||||||
|  |     font-family: inherit; | ||||||
|  |     font-size: inherit; | ||||||
|  |     line-height: inherit; | ||||||
|  |     display: block; | ||||||
|  |     width: 100%; | ||||||
|     padding: 4px 14px; |     padding: 4px 14px; | ||||||
|  |     border: 0; | ||||||
|  |     margin: 0; | ||||||
|     box-sizing: border-box; |     box-sizing: border-box; | ||||||
|     text-decoration: none; |     text-decoration: none; | ||||||
|     background: $ui-secondary-color; |     background: $ui-secondary-color; | ||||||
|     color: $inverted-text-color; |     color: inherit; | ||||||
|     overflow: hidden; |     overflow: hidden; | ||||||
|     text-overflow: ellipsis; |     text-overflow: ellipsis; | ||||||
|     white-space: nowrap; |     white-space: nowrap; | ||||||
|  |     text-align: inherit; | ||||||
| 
 | 
 | ||||||
|     &:focus, |     &:focus, | ||||||
|     &:hover, |     &: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 { | .dropdown--active .dropdown__content { | ||||||
|   display: block; |   display: block; | ||||||
|   line-height: 18px; |   line-height: 18px; | ||||||
|  | @ -3631,36 +3717,48 @@ a.status-card.compact:hover { | ||||||
|   top: 50%; |   top: 50%; | ||||||
|   left: 50%; |   left: 50%; | ||||||
|   transform: translate(-50%, -50%); |   transform: translate(-50%, -50%); | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   span { | .circular-progress { | ||||||
|     display: block; |   color: lighten($ui-base-color, 26%); | ||||||
|     float: left; |   animation: 1.4s linear 0s infinite normal none running simple-rotate; | ||||||
|     transform: translateX(-50%); | 
 | ||||||
|     margin: 82px 0 0 50%; |   circle { | ||||||
|     white-space: nowrap; |     stroke: currentColor; | ||||||
|  |     stroke-dasharray: 80px, 200px; | ||||||
|  |     stroke-dashoffset: 0; | ||||||
|  |     animation: circular-progress 1.4s ease-in-out infinite; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .loading-indicator__figure { | @keyframes circular-progress { | ||||||
|   position: absolute; |   0% { | ||||||
|   top: 50%; |     stroke-dasharray: 1px, 200px; | ||||||
|   left: 50%; |     stroke-dashoffset: 0; | ||||||
|   transform: translate(-50%, -50%); |   } | ||||||
|   width: 42px; | 
 | ||||||
|   height: 42px; |   50% { | ||||||
|   box-sizing: border-box; |     stroke-dasharray: 100px, 200px; | ||||||
|   background-color: transparent; |     stroke-dashoffset: -15px; | ||||||
|   border: 0 solid lighten($ui-base-color, 26%); |   } | ||||||
|   border-width: 6px; | 
 | ||||||
|   border-radius: 50%; |   100% { | ||||||
|  |     stroke-dasharray: 100px, 200px; | ||||||
|  |     stroke-dashoffset: -125px; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .no-reduce-motion .loading-indicator span { | @keyframes simple-rotate { | ||||||
|   animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1); |   0% { | ||||||
| } |     transform: rotate(0deg); | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
| .no-reduce-motion .loading-indicator__figure { |   100% { | ||||||
|   animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1); |     transform: rotate(360deg); | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @keyframes spring-rotate-in { | @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 { | .video-error-cover { | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   background: $base-overlay-background; |   background: $base-overlay-background; | ||||||
|  | @ -4940,7 +5004,8 @@ a.status-card.compact:hover { | ||||||
| .report-modal, | .report-modal, | ||||||
| .actions-modal, | .actions-modal, | ||||||
| .mute-modal, | .mute-modal, | ||||||
| .block-modal { | .block-modal, | ||||||
|  | .compare-history-modal { | ||||||
|   background: lighten($ui-secondary-color, 8%); |   background: lighten($ui-secondary-color, 8%); | ||||||
|   color: $inverted-text-color; |   color: $inverted-text-color; | ||||||
|   border-radius: 8px; |   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 { | .loading-bar { | ||||||
|   background-color: $highlight-text-color; |   background-color: $highlight-text-color; | ||||||
|   height: 3px; |   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 = 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 | ||||||
| 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 = 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 |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  | @ -32,7 +32,7 @@ class Formatter | ||||||
|   include ActionView::Helpers::TextHelper |   include ActionView::Helpers::TextHelper | ||||||
| 
 | 
 | ||||||
|   def format(status, **options) |   def format(status, **options) | ||||||
|     if status.reblog? |     if status.respond_to?(:reblog?) && status.reblog? | ||||||
|       prepend_reblog = status.reblog.account.acct |       prepend_reblog = status.reblog.account.acct | ||||||
|       status         = status.proper |       status         = status.proper | ||||||
|     else |     else | ||||||
|  | @ -53,7 +53,7 @@ class Formatter | ||||||
|       return html.html_safe # rubocop:disable Rails/OutputSafety |       return html.html_safe # rubocop:disable Rails/OutputSafety | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     linkable_accounts = status.active_mentions.map(&:account) |     linkable_accounts = status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [] | ||||||
|     linkable_accounts << status.account |     linkable_accounts << status.account | ||||||
| 
 | 
 | ||||||
|     html = raw_content |     html = raw_content | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ class AccountSuggestions::GlobalSource < AccountSuggestions::Source | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def get(account, skip_account_ids: [], limit: 40) |   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( |     as_ordered_suggestions( | ||||||
|       scope(account).where(id: account_ids), |       scope(account).where(id: account_ids), | ||||||
|  |  | ||||||
|  | @ -20,4 +20,9 @@ class StatusEdit < ApplicationRecord | ||||||
|   default_scope { order(id: :asc) } |   default_scope { order(id: :asc) } | ||||||
| 
 | 
 | ||||||
|   delegate :local?, to: :status |   delegate :local?, to: :status | ||||||
|  | 
 | ||||||
|  |   def emojis | ||||||
|  |     return @emojis if defined?(@emojis) | ||||||
|  |     @emojis = CustomEmoji.from_text([spoiler_text, text].join(' '), status.account.domain) | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -1,6 +1,14 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class REST::StatusEditSerializer < ActiveModel::Serializer | class REST::StatusEditSerializer < ActiveModel::Serializer | ||||||
|   attributes :text, :spoiler_text, :media_attachments_changed, |   has_one :account, serializer: REST::AccountSerializer | ||||||
|              :created_at | 
 | ||||||
|  |   attributes :content, :spoiler_text, | ||||||
|  |              :media_attachments_changed, :created_at | ||||||
|  | 
 | ||||||
|  |   has_many :emojis, serializer: REST::CustomEmojiSerializer | ||||||
|  | 
 | ||||||
|  |   def content | ||||||
|  |     Formatter.instance.format(object) | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -147,7 +147,7 @@ | ||||||
| 
 | 
 | ||||||
|           %tr |           %tr | ||||||
|             %th= t('simple_form.labels.defaults.locale') |             %th= t('simple_form.labels.defaults.locale') | ||||||
|             %td= @account.user_locale |             %td= standard_locale_name(@account.user_locale) | ||||||
|             %td |             %td | ||||||
| 
 | 
 | ||||||
|           %tr |           %tr | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ | ||||||
|     .filter-subset.filter-subset--with-select |     .filter-subset.filter-subset--with-select | ||||||
|       %strong= t('admin.follow_recommendations.language') |       %strong= t('admin.follow_recommendations.language') | ||||||
|       .input.select.optional |       .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 |     .filter-subset | ||||||
|       %strong= t('admin.follow_recommendations.status') |       %strong= t('admin.follow_recommendations.status') | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ | ||||||
|         • |         • | ||||||
| 
 | 
 | ||||||
|       - if preview_card.language.present? |       - 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 }) |       = 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| | = simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f| | ||||||
|   .fields-group |   .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 |   - unless I18n.locale == :en | ||||||
|     .flash-message.translation-prompt |     .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 |       = 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 |     .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 |   .fields-group | ||||||
|     = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label |     = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label | ||||||
|  | @ -41,7 +41,7 @@ | ||||||
|   %h4= t 'preferences.public_timelines' |   %h4= t 'preferences.public_timelines' | ||||||
| 
 | 
 | ||||||
|   .fields-group |   .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 |   .actions | ||||||
|     = f.button :button, t('generic.save_changes'), type: :submit |     = f.button :button, t('generic.save_changes'), type: :submit | ||||||
|  |  | ||||||
|  | @ -9,9 +9,15 @@ describe LanguagesHelper do | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe 'human_locale' do |   describe 'native_locale_name' do | ||||||
|     it 'finds the human readable local description from a key' do |     it 'finds the human readable native name from a key' do | ||||||
|       expect(helper.human_locale(:en)).to eq('English') |       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 |   end | ||||||
| end | end | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue