Merge tag 'v4.3.0-rc.1'
This commit is contained in:
		
				commit
				
					
						26c9b9ba39
					
				
			
		
					 3459 changed files with 130932 additions and 69993 deletions
				
			
		|  | @ -1,11 +1,12 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| import { useCallback } from 'react'; | ||||
| 
 | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { defineMessages, useIntl } from 'react-intl'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { useDispatch } from 'react-redux'; | ||||
| 
 | ||||
| import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; | ||||
| import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, | ||||
|  | @ -23,49 +24,40 @@ const messages = defineMessages({ | |||
|   bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, | ||||
| }); | ||||
| 
 | ||||
| class ActionBar extends PureComponent { | ||||
| export const ActionBar = () => { | ||||
|   const dispatch = useDispatch(); | ||||
|   const intl = useIntl(); | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map.isRequired, | ||||
|     onLogout: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
|   const handleLogoutClick = useCallback(() => { | ||||
|     dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' })); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   handleLogout = () => { | ||||
|     this.props.onLogout(); | ||||
|   }; | ||||
|   let menu = []; | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl } = this.props; | ||||
|   menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); | ||||
|   menu.push(null); | ||||
|   menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }); | ||||
|   menu.push(null); | ||||
|   menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); | ||||
|   menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); | ||||
|   menu.push(null); | ||||
|   menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick }); | ||||
| 
 | ||||
|     let menu = []; | ||||
| 
 | ||||
|     menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); | ||||
|     menu.push(null); | ||||
|     menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }); | ||||
|     menu.push(null); | ||||
|     menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); | ||||
|     menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); | ||||
|     menu.push(null); | ||||
|     menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout }); | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose__action-bar'> | ||||
|         <div className='compose__action-bar-dropdown'> | ||||
|           <DropdownMenuContainer items={menu} icon='bars' size={18} direction='right' /> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default injectIntl(ActionBar); | ||||
|   return ( | ||||
|     <DropdownMenuContainer | ||||
|       items={menu} | ||||
|       icon='bars' | ||||
|       iconComponent={MoreHorizIcon} | ||||
|       size={24} | ||||
|       direction='right' | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ import { DisplayName } from '../../../components/display_name'; | |||
| export default class AutosuggestAccount extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map.isRequired, | ||||
|     account: ImmutablePropTypes.record.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|  | @ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent { | |||
| 
 | ||||
|     return ( | ||||
|       <div className='autosuggest-account' title={account.get('acct')}> | ||||
|         <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> | ||||
|         <Avatar account={account} size={24} /> | ||||
|         <DisplayName account={account} /> | ||||
|       </div> | ||||
|     ); | ||||
|  |  | |||
|  | @ -1,26 +1,18 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| 
 | ||||
| import { length } from 'stringz'; | ||||
| 
 | ||||
| export default class CharacterCounter extends PureComponent { | ||||
| export const CharacterCounter = ({ text, max }) => { | ||||
|   const diff = max - length(text); | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     text: PropTypes.string.isRequired, | ||||
|     max: PropTypes.number.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   checkRemainingText (diff) { | ||||
|     if (diff < 0) { | ||||
|       return <span className='character-counter character-counter--over'>{diff}</span>; | ||||
|     } | ||||
| 
 | ||||
|     return <span className='character-counter'>{diff}</span>; | ||||
|   if (diff < 0) { | ||||
|     return <span className='character-counter character-counter--over'>{diff}</span>; | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const diff = this.props.max - length(this.props.text); | ||||
|     return this.checkRemainingText(diff); | ||||
|   } | ||||
|   return <span className='character-counter'>{diff}</span>; | ||||
| }; | ||||
| 
 | ||||
| } | ||||
| CharacterCounter.propTypes = { | ||||
|   text: PropTypes.string.isRequired, | ||||
|   max: PropTypes.number.isRequired, | ||||
| }; | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { createRef } from 'react'; | ||||
| 
 | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| 
 | ||||
|  | @ -9,41 +10,36 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
| 
 | ||||
| import { length } from 'stringz'; | ||||
| 
 | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| 
 | ||||
| import AutosuggestInput from '../../../components/autosuggest_input'; | ||||
| import AutosuggestTextarea from '../../../components/autosuggest_textarea'; | ||||
| import Button from '../../../components/button'; | ||||
| import { Button } from '../../../components/button'; | ||||
| import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; | ||||
| import LanguageDropdown from '../containers/language_dropdown_container'; | ||||
| import PollButtonContainer from '../containers/poll_button_container'; | ||||
| import PollFormContainer from '../containers/poll_form_container'; | ||||
| import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; | ||||
| import ReplyIndicatorContainer from '../containers/reply_indicator_container'; | ||||
| import SpoilerButtonContainer from '../containers/spoiler_button_container'; | ||||
| import UploadButtonContainer from '../containers/upload_button_container'; | ||||
| import UploadFormContainer from '../containers/upload_form_container'; | ||||
| import WarningContainer from '../containers/warning_container'; | ||||
| import { countableText } from '../util/counter'; | ||||
| 
 | ||||
| import CharacterCounter from './character_counter'; | ||||
| import { CharacterCounter } from './character_counter'; | ||||
| import { EditIndicator } from './edit_indicator'; | ||||
| import { NavigationBar } from './navigation_bar'; | ||||
| import { PollForm } from "./poll_form"; | ||||
| import { ReplyIndicator } from './reply_indicator'; | ||||
| import { UploadForm } from './upload_form'; | ||||
| 
 | ||||
| const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, | ||||
|   spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, | ||||
|   publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }, | ||||
|   publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, | ||||
|   saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, | ||||
|   spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' }, | ||||
|   publish: { id: 'compose_form.publish', defaultMessage: 'Post' }, | ||||
|   saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' }, | ||||
|   reply: { id: 'compose_form.reply', defaultMessage: 'Reply' }, | ||||
| }); | ||||
| 
 | ||||
| class ComposeForm extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     text: PropTypes.string.isRequired, | ||||
|  | @ -67,10 +63,12 @@ class ComposeForm extends ImmutablePureComponent { | |||
|     onPaste: PropTypes.func.isRequired, | ||||
|     onPickEmoji: PropTypes.func.isRequired, | ||||
|     autoFocus: PropTypes.bool, | ||||
|     withoutNavigation: PropTypes.bool, | ||||
|     anyMedia: PropTypes.bool, | ||||
|     isInReply: PropTypes.bool, | ||||
|     singleColumn: PropTypes.bool, | ||||
|     lang: PropTypes.string, | ||||
|     maxChars: PropTypes.number, | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
|  | @ -81,6 +79,11 @@ class ComposeForm extends ImmutablePureComponent { | |||
|     highlighted: false, | ||||
|   }; | ||||
| 
 | ||||
|   constructor(props) { | ||||
|     super(props); | ||||
|     this.textareaRef = createRef(null); | ||||
|   } | ||||
| 
 | ||||
|   handleChange = (e) => { | ||||
|     this.props.onChange(e.target.value); | ||||
|   }; | ||||
|  | @ -96,25 +99,25 @@ class ComposeForm extends ImmutablePureComponent { | |||
|   }; | ||||
| 
 | ||||
|   canSubmit = () => { | ||||
|     const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; | ||||
|     const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxChars } = this.props; | ||||
|     const fulltext = this.getFulltextForCharacterCounting(); | ||||
|     const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; | ||||
| 
 | ||||
|     return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 640 || (isOnlyWhitespace && !anyMedia)); | ||||
|     return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia)); | ||||
|   }; | ||||
| 
 | ||||
|   handleSubmit = (e) => { | ||||
|     if (this.props.text !== this.autosuggestTextarea.textarea.value) { | ||||
|     if (this.props.text !== this.textareaRef.current.value) { | ||||
|       // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) | ||||
|       // Update the state to match the current text | ||||
|       this.props.onChange(this.autosuggestTextarea.textarea.value); | ||||
|       this.props.onChange(this.textareaRef.current.value); | ||||
|     } | ||||
| 
 | ||||
|     if (!this.canSubmit()) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.props.onSubmit(this.context.router ? this.context.router.history : null); | ||||
|     this.props.onSubmit(); | ||||
| 
 | ||||
|     if (e) { | ||||
|       e.preventDefault(); | ||||
|  | @ -186,26 +189,22 @@ class ComposeForm extends ImmutablePureComponent { | |||
|       // immediately selectable, we have to wait for observers to run, as | ||||
|       // described in https://github.com/WICG/inert#performance-and-gotchas | ||||
|       Promise.resolve().then(() => { | ||||
|         this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); | ||||
|         this.autosuggestTextarea.textarea.focus(); | ||||
|         this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd); | ||||
|         this.textareaRef.current.focus(); | ||||
|         this.setState({ highlighted: true }); | ||||
|         this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); | ||||
|       }).catch(console.error); | ||||
|     } else if(prevProps.isSubmitting && !this.props.isSubmitting) { | ||||
|       this.autosuggestTextarea.textarea.focus(); | ||||
|       this.textareaRef.current.focus(); | ||||
|     } else if (this.props.spoiler !== prevProps.spoiler) { | ||||
|       if (this.props.spoiler) { | ||||
|         this.spoilerText.input.focus(); | ||||
|       } else if (prevProps.spoiler) { | ||||
|         this.autosuggestTextarea.textarea.focus(); | ||||
|         this.textareaRef.current.focus(); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   setAutosuggestTextarea = (c) => { | ||||
|     this.autosuggestTextarea = c; | ||||
|   }; | ||||
| 
 | ||||
|   setSpoilerText = (c) => { | ||||
|     this.spoilerText = c; | ||||
|   }; | ||||
|  | @ -216,100 +215,97 @@ class ComposeForm extends ImmutablePureComponent { | |||
| 
 | ||||
|   handleEmojiPick = (data) => { | ||||
|     const { text }     = this.props; | ||||
|     const position     = this.autosuggestTextarea.textarea.selectionStart; | ||||
|     const position     = this.textareaRef.current.selectionStart; | ||||
|     const needsSpace   = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]); | ||||
| 
 | ||||
|     this.props.onPickEmoji(position, data, needsSpace); | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, onPaste, autoFocus } = this.props; | ||||
|     const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props; | ||||
|     const { highlighted } = this.state; | ||||
|     const disabled = this.props.isSubmitting; | ||||
| 
 | ||||
|     let publishText = ''; | ||||
| 
 | ||||
|     if (this.props.isEditing) { | ||||
|       publishText = intl.formatMessage(messages.saveChanges); | ||||
|     } else if (this.props.privacy === 'private' || this.props.privacy === 'direct') { | ||||
|       publishText = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>; | ||||
|     } else { | ||||
|       publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <form className='compose-form' onSubmit={this.handleSubmit}> | ||||
|         <ReplyIndicator /> | ||||
|         {!withoutNavigation && <NavigationBar />} | ||||
|         <WarningContainer /> | ||||
| 
 | ||||
|         <ReplyIndicatorContainer /> | ||||
|         <div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}> | ||||
|           <div className='compose-form__scrollable'> | ||||
|             <EditIndicator /> | ||||
| 
 | ||||
|         <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}> | ||||
|           <AutosuggestInput | ||||
|             placeholder={intl.formatMessage(messages.spoiler_placeholder)} | ||||
|             value={this.props.spoilerText} | ||||
|             onChange={this.handleChangeSpoilerText} | ||||
|             onKeyDown={this.handleKeyDown} | ||||
|             disabled={!this.props.spoiler} | ||||
|             ref={this.setSpoilerText} | ||||
|             suggestions={this.props.suggestions} | ||||
|             onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|             onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|             onSuggestionSelected={this.onSpoilerSuggestionSelected} | ||||
|             searchTokens={[':']} | ||||
|             id='cw-spoiler-input' | ||||
|             className='spoiler-input__input' | ||||
|             lang={this.props.lang} | ||||
|             spellCheck | ||||
|           /> | ||||
|         </div> | ||||
|             {this.props.spoiler && ( | ||||
|               <div className='spoiler-input'> | ||||
|                 <div className='spoiler-input__border' /> | ||||
| 
 | ||||
|         <div className={classNames('compose-form__highlightable', { active: highlighted })}> | ||||
|           <AutosuggestTextarea | ||||
|             ref={this.setAutosuggestTextarea} | ||||
|             placeholder={intl.formatMessage(messages.placeholder)} | ||||
|             disabled={disabled} | ||||
|             value={this.props.text} | ||||
|             onChange={this.handleChange} | ||||
|             suggestions={this.props.suggestions} | ||||
|             onFocus={this.handleFocus} | ||||
|             onKeyDown={this.handleKeyDown} | ||||
|             onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|             onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|             onSuggestionSelected={this.onSuggestionSelected} | ||||
|             onPaste={onPaste} | ||||
|             autoFocus={autoFocus} | ||||
|             lang={this.props.lang} | ||||
|           > | ||||
|             <div className='compose-form__modifiers'> | ||||
|               <UploadFormContainer /> | ||||
|               <PollFormContainer /> | ||||
|             </div> | ||||
|           </AutosuggestTextarea> | ||||
|           <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> | ||||
|                 <AutosuggestInput | ||||
|                   placeholder={intl.formatMessage(messages.spoiler_placeholder)} | ||||
|                   value={this.props.spoilerText} | ||||
|                   disabled={disabled} | ||||
|                   onChange={this.handleChangeSpoilerText} | ||||
|                   onKeyDown={this.handleKeyDown} | ||||
|                   ref={this.setSpoilerText} | ||||
|                   suggestions={this.props.suggestions} | ||||
|                   onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|                   onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|                   onSuggestionSelected={this.onSpoilerSuggestionSelected} | ||||
|                   searchTokens={[':']} | ||||
|                   id='cw-spoiler-input' | ||||
|                   className='spoiler-input__input' | ||||
|                   lang={this.props.lang} | ||||
|                   spellCheck | ||||
|                 /> | ||||
| 
 | ||||
|           <div className='compose-form__buttons-wrapper'> | ||||
|             <div className='compose-form__buttons'> | ||||
|               <UploadButtonContainer /> | ||||
|               <PollButtonContainer /> | ||||
|                 <div className='spoiler-input__border' /> | ||||
|               </div> | ||||
|             )} | ||||
| 
 | ||||
|             <AutosuggestTextarea | ||||
|               ref={this.textareaRef} | ||||
|               placeholder={intl.formatMessage(messages.placeholder)} | ||||
|               disabled={disabled} | ||||
|               value={this.props.text} | ||||
|               onChange={this.handleChange} | ||||
|               suggestions={this.props.suggestions} | ||||
|               onFocus={this.handleFocus} | ||||
|               onKeyDown={this.handleKeyDown} | ||||
|               onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|               onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|               onSuggestionSelected={this.onSuggestionSelected} | ||||
|               onPaste={onPaste} | ||||
|               autoFocus={autoFocus} | ||||
|               lang={this.props.lang} | ||||
|             /> | ||||
|           </div> | ||||
| 
 | ||||
|           <UploadForm /> | ||||
|           <PollForm /> | ||||
| 
 | ||||
|           <div className='compose-form__footer'> | ||||
|             <div className='compose-form__dropdowns'> | ||||
|               <PrivacyDropdownContainer disabled={this.props.isEditing} /> | ||||
|               <SpoilerButtonContainer /> | ||||
|               <LanguageDropdown /> | ||||
|             </div> | ||||
| 
 | ||||
|             <div className='character-counter__wrapper'> | ||||
|               <CharacterCounter max={640} text={this.getFulltextForCharacterCounting()} /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|             <div className='compose-form__actions'> | ||||
|               <div className='compose-form__buttons'> | ||||
|                 <UploadButtonContainer /> | ||||
|                 <PollButtonContainer /> | ||||
|                 <SpoilerButtonContainer /> | ||||
|                 <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> | ||||
|                 <CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} /> | ||||
|               </div> | ||||
| 
 | ||||
|         <div className='compose-form__publish'> | ||||
|           <div className='compose-form__publish-button-wrapper'> | ||||
|             <Button | ||||
|               type='submit' | ||||
|               text={publishText} | ||||
|               disabled={!this.canSubmit()} | ||||
|               block | ||||
|             /> | ||||
|               <div className='compose-form__submit'> | ||||
|                 <Button | ||||
|                   type='submit' | ||||
|                   text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))} | ||||
|                   disabled={!this.canSubmit()} | ||||
|                 /> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </form> | ||||
|  |  | |||
|  | @ -0,0 +1,66 @@ | |||
| import { useCallback } from 'react'; | ||||
| 
 | ||||
| import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import { Link } from 'react-router-dom'; | ||||
| 
 | ||||
| import { useDispatch, useSelector } from 'react-redux'; | ||||
| 
 | ||||
| import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; | ||||
| import CloseIcon from '@/material-icons/400-24px/close.svg?react'; | ||||
| import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; | ||||
| import { cancelReplyCompose } from 'mastodon/actions/compose'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; | ||||
| import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, | ||||
| }); | ||||
| 
 | ||||
| export const EditIndicator = () => { | ||||
|   const intl = useIntl(); | ||||
|   const dispatch = useDispatch(); | ||||
|   const id = useSelector(state => state.getIn(['compose', 'id'])); | ||||
|   const status = useSelector(state => state.getIn(['statuses', id])); | ||||
|   const account = useSelector(state => state.getIn(['accounts', status?.get('account')])); | ||||
| 
 | ||||
|   const handleCancelClick = useCallback(() => { | ||||
|     dispatch(cancelReplyCompose()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   if (!status) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='edit-indicator'> | ||||
|       <div className='edit-indicator__header'> | ||||
|         <div className='edit-indicator__display-name'> | ||||
|           <Link to={`/@${account.get('acct')}`}>@{account.get('acct')}</Link> | ||||
|           · | ||||
|           <Link to={`/@${account.get('acct')}/${status.get('id')}`}><RelativeTimestamp timestamp={status.get('created_at')} /></Link> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='edit-indicator__cancel'> | ||||
|           <IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={handleCancelClick} inverted /> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <EmbeddedStatusContent | ||||
|         className='edit-indicator__content translate' | ||||
|         content={status.get('contentHtml')} | ||||
|         language={status.get('language')} | ||||
|         mentions={status.get('mentions')} | ||||
|       /> | ||||
| 
 | ||||
|       {(status.get('poll') || status.get('media_attachments').size > 0) && ( | ||||
|         <div className='edit-indicator__attachments'> | ||||
|           {status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>} | ||||
|           {status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>} | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
| import { supportsPassiveEvents } from 'detect-passive-events'; | ||||
| import Overlay from 'react-overlays/Overlay'; | ||||
| 
 | ||||
| import MoodIcon from '@/material-icons/400-20px/mood.svg?react'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| import { assetHost } from 'mastodon/utils/config'; | ||||
| 
 | ||||
| import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; | ||||
|  | @ -162,6 +164,7 @@ class EmojiPickerMenuImpl extends PureComponent { | |||
|     intl: PropTypes.object.isRequired, | ||||
|     skinTone: PropTypes.number.isRequired, | ||||
|     onSkinTone: PropTypes.func.isRequired, | ||||
|     pickerButtonRef: PropTypes.func.isRequired | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
|  | @ -176,7 +179,7 @@ class EmojiPickerMenuImpl extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   handleDocumentClick = e => { | ||||
|     if (this.node && !this.node.contains(e.target)) { | ||||
|     if (this.node && !this.node.contains(e.target) && !this.props.pickerButtonRef.contains(e.target)) { | ||||
|       this.props.onClose(); | ||||
|     } | ||||
|   }; | ||||
|  | @ -231,6 +234,7 @@ class EmojiPickerMenuImpl extends PureComponent { | |||
|       emoji.native = emoji.colons; | ||||
|     } | ||||
|     if (!(event.ctrlKey || event.metaKey)) { | ||||
| 
 | ||||
|       this.props.onClose(); | ||||
|     } | ||||
|     this.props.onPick(emoji); | ||||
|  | @ -321,12 +325,12 @@ class EmojiPickerDropdown extends PureComponent { | |||
|     onPickEmoji: PropTypes.func.isRequired, | ||||
|     onSkinTone: PropTypes.func.isRequired, | ||||
|     skinTone: PropTypes.number.isRequired, | ||||
|     button: PropTypes.node, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|     active: false, | ||||
|     loading: false, | ||||
|     placement: 'bottom', | ||||
|   }; | ||||
| 
 | ||||
|   setRef = (c) => { | ||||
|  | @ -378,24 +382,29 @@ class EmojiPickerDropdown extends PureComponent { | |||
|     return this.target; | ||||
|   }; | ||||
| 
 | ||||
|   handleOverlayEnter = (state) => { | ||||
|     this.setState({ placement: state.placement }); | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; | ||||
|     const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; | ||||
|     const title = intl.formatMessage(messages.emoji); | ||||
|     const { active, loading } = this.state; | ||||
|     const { active, loading, placement } = this.state; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> | ||||
|         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> | ||||
|           {button || <img | ||||
|             className={classNames('emojione', { 'pulse-loading': active && loading })} | ||||
|             alt='🙂' | ||||
|             src={`${assetHost}/emoji/1f642.svg`} | ||||
|           />} | ||||
|         </div> | ||||
|       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}> | ||||
|         <IconButton | ||||
|           title={title} | ||||
|           aria-expanded={active} | ||||
|           active={active} | ||||
|           iconComponent={MoodIcon} | ||||
|           onClick={this.onToggle} | ||||
|           inverted | ||||
|         /> | ||||
| 
 | ||||
|         <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}> | ||||
|         <Overlay show={active} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> | ||||
|           {({ props, placement })=> ( | ||||
|             <div {...props} style={{ ...props.style, width: 299 }}> | ||||
|             <div {...props} style={{ ...props.style }}> | ||||
|               <div className={`dropdown-animation ${placement}`}> | ||||
|                 <EmojiPickerMenu | ||||
|                   custom_emojis={this.props.custom_emojis} | ||||
|  | @ -405,6 +414,7 @@ class EmojiPickerDropdown extends PureComponent { | |||
|                   onSkinTone={onSkinTone} | ||||
|                   skinTone={skinTone} | ||||
|                   frequentlyUsedEmojis={frequentlyUsedEmojis} | ||||
|                   pickerButtonRef={this.target} | ||||
|                 /> | ||||
|               </div> | ||||
|             </div> | ||||
|  |  | |||
|  | @ -9,10 +9,11 @@ import { supportsPassiveEvents } from 'detect-passive-events'; | |||
| import fuzzysort from 'fuzzysort'; | ||||
| import Overlay from 'react-overlays/Overlay'; | ||||
| 
 | ||||
| import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; | ||||
| import SearchIcon from '@/material-icons/400-24px/search.svg?react'; | ||||
| import TranslateIcon from '@/material-icons/400-24px/translate.svg?react'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import { languages as preloadedLanguages } from 'mastodon/initial_state'; | ||||
| import { loupeIcon, deleteIcon } from 'mastodon/utils/icons'; | ||||
| 
 | ||||
| import TextIconButton from './text_icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' }, | ||||
|  | @ -109,18 +110,6 @@ class LanguageDropdownMenu extends PureComponent { | |||
|     }).map(result => result.obj); | ||||
|   } | ||||
| 
 | ||||
|   frequentlyUsed () { | ||||
|     const { languages, value } = this.props; | ||||
|     const current = languages.find(lang => lang[0] === value); | ||||
|     const results = []; | ||||
| 
 | ||||
|     if (current) { | ||||
|       results.push(current); | ||||
|     } | ||||
| 
 | ||||
|     return results; | ||||
|   } | ||||
| 
 | ||||
|   handleClick = e => { | ||||
|     const value = e.currentTarget.getAttribute('data-index'); | ||||
| 
 | ||||
|  | @ -140,6 +129,7 @@ class LanguageDropdownMenu extends PureComponent { | |||
|     case 'Escape': | ||||
|       onClose(); | ||||
|       break; | ||||
|     case ' ': | ||||
|     case 'Enter': | ||||
|       this.handleClick(e); | ||||
|       break; | ||||
|  | @ -231,7 +221,7 @@ class LanguageDropdownMenu extends PureComponent { | |||
|       <div ref={this.setRef}> | ||||
|         <div className='emoji-mart-search'> | ||||
|           <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} /> | ||||
|           <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button> | ||||
|           <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}><Icon icon={!isSearching ? SearchIcon : CancelIcon} /></button> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> | ||||
|  | @ -250,7 +240,6 @@ class LanguageDropdown extends PureComponent { | |||
|     frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string), | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     onChange: PropTypes.func, | ||||
|     onClose: PropTypes.func, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -267,14 +256,11 @@ class LanguageDropdown extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   handleClose = () => { | ||||
|     const { value, onClose } = this.props; | ||||
| 
 | ||||
|     if (this.state.open && this.activeElement) { | ||||
|       this.activeElement.focus({ preventScroll: true }); | ||||
|     } | ||||
| 
 | ||||
|     this.setState({ open: false }); | ||||
|     onClose(value); | ||||
|   }; | ||||
| 
 | ||||
|   handleChange = value => { | ||||
|  | @ -297,20 +283,24 @@ class LanguageDropdown extends PureComponent { | |||
|   render () { | ||||
|     const { value, intl, frequentlyUsedLanguages } = this.props; | ||||
|     const { open, placement } = this.state; | ||||
|     const current = preloadedLanguages.find(lang => lang[0] === value) ?? []; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className={classNames('privacy-dropdown', placement, { active: open })}> | ||||
|         <div className='privacy-dropdown__value' ref={this.setTargetRef} > | ||||
|           <TextIconButton | ||||
|             className='privacy-dropdown__value-icon' | ||||
|             label={value && value.toUpperCase()} | ||||
|             title={intl.formatMessage(messages.changeLanguage)} | ||||
|             active={open} | ||||
|             onClick={this.handleToggle} | ||||
|           /> | ||||
|         </div> | ||||
|       <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}> | ||||
|         <button | ||||
|           type='button' | ||||
|           title={intl.formatMessage(messages.changeLanguage)} | ||||
|           aria-expanded={open} | ||||
|           onClick={this.handleToggle} | ||||
|           onMouseDown={this.handleMouseDown} | ||||
|           onKeyDown={this.handleButtonKeyDown} | ||||
|           className={classNames('dropdown-button', { active: open })} | ||||
|         > | ||||
|           <Icon icon={TranslateIcon} /> | ||||
|           <span className='dropdown-button__label'>{current[2] ?? value}</span> | ||||
|         </button> | ||||
| 
 | ||||
|         <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> | ||||
|         <Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> | ||||
|           {({ props, placement }) => ( | ||||
|             <div {...props}> | ||||
|               <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} > | ||||
|  |  | |||
|  | @ -1,50 +1,36 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { useCallback } from 'react'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import { useIntl, defineMessages } from 'react-intl'; | ||||
| 
 | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { useSelector, useDispatch } from 'react-redux'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import CloseIcon from '@/material-icons/400-24px/close.svg?react'; | ||||
| import { cancelReplyCompose } from 'mastodon/actions/compose'; | ||||
| import Account from 'mastodon/components/account'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| import { me } from 'mastodon/initial_state'; | ||||
| 
 | ||||
| import { Avatar } from '../../../components/avatar'; | ||||
| import { ActionBar } from './action_bar'; | ||||
| 
 | ||||
| import ActionBar from './action_bar'; | ||||
| 
 | ||||
| export default class NavigationBar extends ImmutablePureComponent { | ||||
| const messages = defineMessages({ | ||||
|   cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, | ||||
| }); | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     account: ImmutablePropTypes.map.isRequired, | ||||
|     onLogout: PropTypes.func.isRequired, | ||||
|     onClose: PropTypes.func, | ||||
|   }; | ||||
| export const NavigationBar = () => { | ||||
|   const dispatch = useDispatch(); | ||||
|   const intl = useIntl(); | ||||
|   const account = useSelector(state => state.getIn(['accounts', me])); | ||||
|   const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to'])); | ||||
| 
 | ||||
|   render () { | ||||
|     const username = this.props.account.get('acct') | ||||
|     return ( | ||||
|       <div className='navigation-bar'> | ||||
|         <Link to={`/@${username}`}> | ||||
|           <span style={{ display: 'none' }}>{username}</span> | ||||
|           <Avatar account={this.props.account} size={46} /> | ||||
|         </Link> | ||||
|   const handleCancelClick = useCallback(() => { | ||||
|     dispatch(cancelReplyCompose()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|         <div className='navigation-bar__profile'> | ||||
|           <span> | ||||
|             <Link to={`/@${username}`}> | ||||
|               <strong className='navigation-bar__profile-account'>@{username}</strong> | ||||
|             </Link> | ||||
|           </span> | ||||
| 
 | ||||
|           <span> | ||||
|             <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> | ||||
|           </span> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='navigation-bar__actions'> | ||||
|           <ActionBar account={this.props.account} onLogout={this.props.onLogout} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|   return ( | ||||
|     <div className='navigation-bar'> | ||||
|       <Account account={account} minimal /> | ||||
|       {isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ import { PureComponent } from 'react'; | |||
| 
 | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| 
 | ||||
| import BarChart4BarsIcon from '@/material-icons/400-20px/bar_chart_4_bars.svg?react'; | ||||
| 
 | ||||
| import { IconButton } from '../../../components/icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|  | @ -19,7 +21,6 @@ class PollButton extends PureComponent { | |||
| 
 | ||||
|   static propTypes = { | ||||
|     disabled: PropTypes.bool, | ||||
|     unavailable: PropTypes.bool, | ||||
|     active: PropTypes.bool, | ||||
|     onClick: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|  | @ -30,16 +31,13 @@ class PollButton extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, active, unavailable, disabled } = this.props; | ||||
| 
 | ||||
|     if (unavailable) { | ||||
|       return null; | ||||
|     } | ||||
|     const { intl, active, disabled } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-form__poll-button'> | ||||
|         <IconButton | ||||
|           icon='tasks' | ||||
|           iconComponent={BarChart4BarsIcon} | ||||
|           title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)} | ||||
|           disabled={disabled} | ||||
|           onClick={this.handleClick} | ||||
|  |  | |||
|  | @ -1,187 +1,161 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| import { useCallback } from 'react'; | ||||
| 
 | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import { defineMessages, useIntl } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { useDispatch, useSelector } from 'react-redux'; | ||||
| 
 | ||||
| import { | ||||
|   changePollSettings, | ||||
|   changePollOption, | ||||
|   clearComposeSuggestions, | ||||
|   fetchComposeSuggestions, | ||||
|   selectComposeSuggestion, | ||||
| } from 'mastodon/actions/compose'; | ||||
| import AutosuggestInput from 'mastodon/components/autosuggest_input'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, | ||||
|   add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, | ||||
|   remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, | ||||
|   poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, | ||||
|   option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' }, | ||||
|   duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' }, | ||||
|   type: { id: 'compose_form.poll.type', defaultMessage: 'Style' }, | ||||
|   switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' }, | ||||
|   switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' }, | ||||
|   minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, | ||||
|   hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, | ||||
|   days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, | ||||
|   singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' }, | ||||
|   multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' }, | ||||
| }); | ||||
| 
 | ||||
| class OptionIntl extends PureComponent { | ||||
| const Select = ({ label, options, value, onChange }) => { | ||||
|   return ( | ||||
|     <label className='compose-form__poll__select'> | ||||
|       <span className='compose-form__poll__select__label'>{label}</span> | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     title: PropTypes.string.isRequired, | ||||
|     lang: PropTypes.string, | ||||
|     index: PropTypes.number.isRequired, | ||||
|     isPollMultiple: PropTypes.bool, | ||||
|     autoFocus: PropTypes.bool, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|     onRemove: PropTypes.func.isRequired, | ||||
|     onToggleMultiple: PropTypes.func.isRequired, | ||||
|     suggestions: ImmutablePropTypes.list, | ||||
|     onClearSuggestions: PropTypes.func.isRequired, | ||||
|     onFetchSuggestions: PropTypes.func.isRequired, | ||||
|     onSuggestionSelected: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
|       <select className='compose-form__poll__select__value' value={value} onChange={onChange}> | ||||
|         {options.map((option, i) => ( | ||||
|           <option key={i} value={option.value}>{option.label}</option> | ||||
|         ))} | ||||
|       </select> | ||||
|     </label> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
|   handleOptionTitleChange = e => { | ||||
|     this.props.onChange(this.props.index, e.target.value); | ||||
|   }; | ||||
| Select.propTypes = { | ||||
|   label: PropTypes.node, | ||||
|   value: PropTypes.any, | ||||
|   onChange: PropTypes.func, | ||||
|   options: PropTypes.arrayOf(PropTypes.shape({ | ||||
|     label: PropTypes.node, | ||||
|     value: PropTypes.any, | ||||
|   })), | ||||
| }; | ||||
| 
 | ||||
|   handleOptionRemove = () => { | ||||
|     this.props.onRemove(this.props.index); | ||||
|   }; | ||||
| const Option = ({ multipleChoice, index, title, autoFocus }) => { | ||||
|   const intl = useIntl(); | ||||
|   const dispatch = useDispatch(); | ||||
|   const suggestions = useSelector(state => state.getIn(['compose', 'suggestions'])); | ||||
|   const lang = useSelector(state => state.getIn(['compose', 'language'])); | ||||
|   const maxOptions = useSelector(state => state.getIn(['server', 'server', 'configuration', 'polls', 'max_options'])); | ||||
| 
 | ||||
|   const handleChange = useCallback(({ target: { value } }) => { | ||||
|     dispatch(changePollOption(index, value, maxOptions)); | ||||
|   }, [dispatch, index, maxOptions]); | ||||
| 
 | ||||
|   handleToggleMultiple = e => { | ||||
|     this.props.onToggleMultiple(); | ||||
|     e.preventDefault(); | ||||
|     e.stopPropagation(); | ||||
|   }; | ||||
|   const handleSuggestionsFetchRequested = useCallback(token => { | ||||
|     dispatch(fetchComposeSuggestions(token)); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   handleCheckboxKeypress = e => { | ||||
|     if (e.key === 'Enter' || e.key === ' ') { | ||||
|       this.handleToggleMultiple(e); | ||||
|     } | ||||
|   }; | ||||
|   const handleSuggestionsClearRequested = useCallback(() => { | ||||
|     dispatch(clearComposeSuggestions()); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   onSuggestionsClearRequested = () => { | ||||
|     this.props.onClearSuggestions(); | ||||
|   }; | ||||
|   const handleSuggestionSelected = useCallback((tokenStart, token, value) => { | ||||
|     dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index])); | ||||
|   }, [dispatch, index]); | ||||
| 
 | ||||
|   onSuggestionsFetchRequested = (token) => { | ||||
|     this.props.onFetchSuggestions(token); | ||||
|   }; | ||||
|   return ( | ||||
|     <label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}> | ||||
|       <span className={classNames('poll__input', { checkbox: multipleChoice })} /> | ||||
| 
 | ||||
|   onSuggestionSelected = (tokenStart, token, value) => { | ||||
|     this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]); | ||||
|   }; | ||||
|       <AutosuggestInput | ||||
|         placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} | ||||
|         maxLength={50} | ||||
|         value={title} | ||||
|         lang={lang} | ||||
|         spellCheck | ||||
|         onChange={handleChange} | ||||
|         suggestions={suggestions} | ||||
|         onSuggestionsFetchRequested={handleSuggestionsFetchRequested} | ||||
|         onSuggestionsClearRequested={handleSuggestionsClearRequested} | ||||
|         onSuggestionSelected={handleSuggestionSelected} | ||||
|         searchTokens={[':']} | ||||
|         autoFocus={autoFocus} | ||||
|       /> | ||||
|     </label> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props; | ||||
| Option.propTypes = { | ||||
|   title: PropTypes.string.isRequired, | ||||
|   index: PropTypes.number.isRequired, | ||||
|   multipleChoice: PropTypes.bool, | ||||
|   autoFocus: PropTypes.bool, | ||||
| }; | ||||
| 
 | ||||
|     return ( | ||||
|       <li> | ||||
|         <label className='poll__option editable'> | ||||
|           <span | ||||
|             className={classNames('poll__input', { checkbox: isPollMultiple })} | ||||
|             onClick={this.handleToggleMultiple} | ||||
|             onKeyPress={this.handleCheckboxKeypress} | ||||
|             role='button' | ||||
|             tabIndex={0} | ||||
|             title={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)} | ||||
|             aria-label={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)} | ||||
|           /> | ||||
| export const PollForm = () => { | ||||
|   const intl = useIntl(); | ||||
|   const dispatch = useDispatch(); | ||||
|   const poll = useSelector(state => state.getIn(['compose', 'poll'])); | ||||
|   const options = poll?.get('options'); | ||||
|   const expiresIn = poll?.get('expires_in'); | ||||
|   const isMultiple = poll?.get('multiple'); | ||||
| 
 | ||||
|           <AutosuggestInput | ||||
|             placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} | ||||
|             maxLength={50} | ||||
|             value={title} | ||||
|             lang={lang} | ||||
|             spellCheck | ||||
|             onChange={this.handleOptionTitleChange} | ||||
|             suggestions={this.props.suggestions} | ||||
|             onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | ||||
|             onSuggestionsClearRequested={this.onSuggestionsClearRequested} | ||||
|             onSuggestionSelected={this.onSuggestionSelected} | ||||
|             searchTokens={[':']} | ||||
|             autoFocus={autoFocus} | ||||
|           /> | ||||
|         </label> | ||||
|   const handleDurationChange = useCallback(({ target: { value } }) => { | ||||
|     dispatch(changePollSettings(value, isMultiple)); | ||||
|   }, [dispatch, isMultiple]); | ||||
| 
 | ||||
|         <div className='poll__cancel'> | ||||
|           <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} /> | ||||
|         </div> | ||||
|       </li> | ||||
|     ); | ||||
|   const handleTypeChange = useCallback(({ target: { value } }) => { | ||||
|     dispatch(changePollSettings(expiresIn, value === 'true')); | ||||
|   }, [dispatch, expiresIn]); | ||||
| 
 | ||||
|   if (poll === null) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|   return ( | ||||
|     <div className='compose-form__poll'> | ||||
|       {options.map((title, i) => ( | ||||
|         <Option | ||||
|           title={title} | ||||
|           key={i} | ||||
|           index={i} | ||||
|           multipleChoice={isMultiple} | ||||
|           autoFocus={i === 0} | ||||
|         /> | ||||
|       ))} | ||||
| 
 | ||||
| const Option = injectIntl(OptionIntl); | ||||
|       <div className='compose-form__poll__footer'> | ||||
|         <Select label={intl.formatMessage(messages.duration)} options={[ | ||||
|           { value: 300, label: intl.formatMessage(messages.minutes, { number: 5 })}, | ||||
|           { value: 1800, label: intl.formatMessage(messages.minutes, { number: 30 })}, | ||||
|           { value: 3600, label: intl.formatMessage(messages.hours, { number: 1 })}, | ||||
|           { value: 21600, label: intl.formatMessage(messages.hours, { number: 6 })}, | ||||
|           { value: 43200, label: intl.formatMessage(messages.hours, { number: 12 })}, | ||||
|           { value: 86400, label: intl.formatMessage(messages.days, { number: 1 })}, | ||||
|           { value: 259200, label: intl.formatMessage(messages.days, { number: 3 })}, | ||||
|           { value: 604800, label: intl.formatMessage(messages.days, { number: 7 })}, | ||||
|         ]} value={expiresIn} onChange={handleDurationChange} /> | ||||
| 
 | ||||
| class PollForm extends ImmutablePureComponent { | ||||
|         <div className='compose-form__poll__footer__sep' /> | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     options: ImmutablePropTypes.list, | ||||
|     lang: PropTypes.string, | ||||
|     expiresIn: PropTypes.number, | ||||
|     isMultiple: PropTypes.bool, | ||||
|     onChangeOption: PropTypes.func.isRequired, | ||||
|     onAddOption: PropTypes.func.isRequired, | ||||
|     onRemoveOption: PropTypes.func.isRequired, | ||||
|     onChangeSettings: PropTypes.func.isRequired, | ||||
|     suggestions: ImmutablePropTypes.list, | ||||
|     onClearSuggestions: PropTypes.func.isRequired, | ||||
|     onFetchSuggestions: PropTypes.func.isRequired, | ||||
|     onSuggestionSelected: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   handleAddOption = () => { | ||||
|     this.props.onAddOption(''); | ||||
|   }; | ||||
| 
 | ||||
|   handleSelectDuration = e => { | ||||
|     this.props.onChangeSettings(e.target.value, this.props.isMultiple); | ||||
|   }; | ||||
| 
 | ||||
|   handleToggleMultiple = () => { | ||||
|     this.props.onChangeSettings(this.props.expiresIn, !this.props.isMultiple); | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props; | ||||
| 
 | ||||
|     if (!options) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const autoFocusIndex = options.indexOf(''); | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-form__poll-wrapper'> | ||||
|         <ul> | ||||
|           {options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} autoFocus={i === autoFocusIndex} {...other} />)} | ||||
|         </ul> | ||||
| 
 | ||||
|         <div className='poll__footer'> | ||||
|           <button type='button' disabled={options.size >= 4} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> | ||||
| 
 | ||||
|           {/* eslint-disable-next-line jsx-a11y/no-onchange */} | ||||
|           <select value={expiresIn} onChange={this.handleSelectDuration}> | ||||
|             <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> | ||||
|             <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> | ||||
|             <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> | ||||
|             <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> | ||||
|             <option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option> | ||||
|             <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> | ||||
|             <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> | ||||
|             <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> | ||||
|           </select> | ||||
|         </div> | ||||
|         <Select label={intl.formatMessage(messages.type)} options={[ | ||||
|           { value: false, label: intl.formatMessage(messages.singleChoice) }, | ||||
|           { value: true, label: intl.formatMessage(messages.multipleChoice) }, | ||||
|         ]} value={isMultiple} onChange={handleTypeChange} /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default injectIntl(PollForm); | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -5,139 +5,28 @@ import { injectIntl, defineMessages } from 'react-intl'; | |||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| 
 | ||||
| import { supportsPassiveEvents } from 'detect-passive-events'; | ||||
| import Overlay from 'react-overlays/Overlay'; | ||||
| 
 | ||||
| import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; | ||||
| import LockIcon from '@/material-icons/400-24px/lock.svg?react'; | ||||
| import PublicIcon from '@/material-icons/400-24px/public.svg?react'; | ||||
| import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react'; | ||||
| import { DropdownSelector } from 'mastodon/components/dropdown_selector'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| 
 | ||||
| import { IconButton } from '../../../components/icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, | ||||
|   public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' }, | ||||
|   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, | ||||
|   unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' }, | ||||
|   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, | ||||
|   private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, | ||||
|   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, | ||||
|   direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, | ||||
|   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, | ||||
|   public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' }, | ||||
|   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' }, | ||||
|   unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' }, | ||||
|   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' }, | ||||
|   private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' }, | ||||
|   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' }, | ||||
|   direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' }, | ||||
|   change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' }, | ||||
|   unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' }, | ||||
| }); | ||||
| 
 | ||||
| const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; | ||||
| 
 | ||||
| class PrivacyDropdownMenu extends PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     style: PropTypes.object, | ||||
|     items: PropTypes.array.isRequired, | ||||
|     value: PropTypes.string.isRequired, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   handleDocumentClick = e => { | ||||
|     if (this.node && !this.node.contains(e.target)) { | ||||
|       this.props.onClose(); | ||||
|       e.stopPropagation(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   handleKeyDown = e => { | ||||
|     const { items } = this.props; | ||||
|     const value = e.currentTarget.getAttribute('data-index'); | ||||
|     const index = items.findIndex(item => { | ||||
|       return (item.value === value); | ||||
|     }); | ||||
|     let element = null; | ||||
| 
 | ||||
|     switch(e.key) { | ||||
|     case 'Escape': | ||||
|       this.props.onClose(); | ||||
|       break; | ||||
|     case 'Enter': | ||||
|       this.handleClick(e); | ||||
|       break; | ||||
|     case 'ArrowDown': | ||||
|       element = this.node.childNodes[index + 1] || this.node.firstChild; | ||||
|       break; | ||||
|     case 'ArrowUp': | ||||
|       element = this.node.childNodes[index - 1] || this.node.lastChild; | ||||
|       break; | ||||
|     case 'Tab': | ||||
|       if (e.shiftKey) { | ||||
|         element = this.node.childNodes[index - 1] || this.node.lastChild; | ||||
|       } else { | ||||
|         element = this.node.childNodes[index + 1] || this.node.firstChild; | ||||
|       } | ||||
|       break; | ||||
|     case 'Home': | ||||
|       element = this.node.firstChild; | ||||
|       break; | ||||
|     case 'End': | ||||
|       element = this.node.lastChild; | ||||
|       break; | ||||
|     } | ||||
| 
 | ||||
|     if (element) { | ||||
|       element.focus(); | ||||
|       this.props.onChange(element.getAttribute('data-index')); | ||||
|       e.preventDefault(); | ||||
|       e.stopPropagation(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   handleClick = e => { | ||||
|     const value = e.currentTarget.getAttribute('data-index'); | ||||
| 
 | ||||
|     e.preventDefault(); | ||||
| 
 | ||||
|     this.props.onClose(); | ||||
|     this.props.onChange(value); | ||||
|   }; | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     document.addEventListener('click', this.handleDocumentClick, { capture: true }); | ||||
|     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||
|     if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); | ||||
|   } | ||||
| 
 | ||||
|   componentWillUnmount () { | ||||
|     document.removeEventListener('click', this.handleDocumentClick, { capture: true }); | ||||
|     document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); | ||||
|   } | ||||
| 
 | ||||
|   setRef = c => { | ||||
|     this.node = c; | ||||
|   }; | ||||
| 
 | ||||
|   setFocusRef = c => { | ||||
|     this.focusedItem = c; | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { style, items, value } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <div style={{ ...style }} role='listbox' ref={this.setRef}> | ||||
|         {items.map(item => ( | ||||
|           <div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}> | ||||
|             <div className='privacy-dropdown__option__icon'> | ||||
|               <Icon id={item.icon} fixedWidth /> | ||||
|             </div> | ||||
| 
 | ||||
|             <div className='privacy-dropdown__option__content'> | ||||
|               <strong>{item.text}</strong> | ||||
|               {item.meta} | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| class PrivacyDropdown extends PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|  | @ -158,30 +47,11 @@ class PrivacyDropdown extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   handleToggle = () => { | ||||
|     if (this.props.isUserTouching && this.props.isUserTouching()) { | ||||
|       if (this.state.open) { | ||||
|         this.props.onModalClose(); | ||||
|       } else { | ||||
|         this.props.onModalOpen({ | ||||
|           actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), | ||||
|           onClick: this.handleModalActionClick, | ||||
|         }); | ||||
|       } | ||||
|     } else { | ||||
|       if (this.state.open && this.activeElement) { | ||||
|         this.activeElement.focus({ preventScroll: true }); | ||||
|       } | ||||
|       this.setState({ open: !this.state.open }); | ||||
|     if (this.state.open && this.activeElement) { | ||||
|       this.activeElement.focus({ preventScroll: true }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   handleModalActionClick = (e) => { | ||||
|     e.preventDefault(); | ||||
| 
 | ||||
|     const { value } = this.options[e.currentTarget.getAttribute('data-index')]; | ||||
| 
 | ||||
|     this.props.onModalClose(); | ||||
|     this.props.onChange(value); | ||||
|     this.setState({ open: !this.state.open }); | ||||
|   }; | ||||
| 
 | ||||
|   handleKeyDown = e => { | ||||
|  | @ -222,14 +92,14 @@ class PrivacyDropdown extends PureComponent { | |||
|     const { intl: { formatMessage } } = this.props; | ||||
| 
 | ||||
|     this.options = [ | ||||
|       { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, | ||||
|       { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, | ||||
|       { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, | ||||
|       { icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, | ||||
|       { icon: 'unlock', iconComponent: QuietTimeIcon,  value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) }, | ||||
|       { icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, | ||||
|     ]; | ||||
| 
 | ||||
|     if (!this.props.noDirect) { | ||||
|       this.options.push( | ||||
|         { icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, | ||||
|         { icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | @ -253,29 +123,26 @@ class PrivacyDropdown extends PureComponent { | |||
|     const valueOption = this.options.find(item => item.value === value); | ||||
| 
 | ||||
|     return ( | ||||
|       <div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}> | ||||
|         <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })} ref={this.setTargetRef}> | ||||
|           <IconButton | ||||
|             className='privacy-dropdown__value-icon' | ||||
|             icon={valueOption.icon} | ||||
|             title={intl.formatMessage(messages.change_privacy)} | ||||
|             size={18} | ||||
|             expanded={open} | ||||
|             active={open} | ||||
|             inverted | ||||
|             onClick={this.handleToggle} | ||||
|             onMouseDown={this.handleMouseDown} | ||||
|             onKeyDown={this.handleButtonKeyDown} | ||||
|             style={{ height: null, lineHeight: '27px' }} | ||||
|             disabled={disabled} | ||||
|           /> | ||||
|         </div> | ||||
|       <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}> | ||||
|         <button | ||||
|           type='button' | ||||
|           title={intl.formatMessage(messages.change_privacy)} | ||||
|           aria-expanded={open} | ||||
|           onClick={this.handleToggle} | ||||
|           onMouseDown={this.handleMouseDown} | ||||
|           onKeyDown={this.handleButtonKeyDown} | ||||
|           disabled={disabled} | ||||
|           className={classNames('dropdown-button', { active: open })} | ||||
|         > | ||||
|           <Icon id={valueOption.icon} icon={valueOption.iconComponent} /> | ||||
|           <span className='dropdown-button__label'>{valueOption.text}</span> | ||||
|         </button> | ||||
| 
 | ||||
|         <Overlay show={open} placement={'bottom'} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> | ||||
|         <Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> | ||||
|           {({ props, placement }) => ( | ||||
|             <div {...props}> | ||||
|               <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}> | ||||
|                 <PrivacyDropdownMenu | ||||
|                 <DropdownSelector | ||||
|                   items={this.options} | ||||
|                   value={value} | ||||
|                   onClose={this.handleClose} | ||||
|  |  | |||
|  | @ -1,75 +1,52 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { useSelector } from 'react-redux'; | ||||
| 
 | ||||
| import AttachmentList from 'mastodon/components/attachment_list'; | ||||
| import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; | ||||
| import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; | ||||
| import { Avatar } from 'mastodon/components/avatar'; | ||||
| import { DisplayName } from 'mastodon/components/display_name'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import { EmbeddedStatusContent } from 'mastodon/features/notifications_v2/components/embedded_status_content'; | ||||
| 
 | ||||
| import { Avatar } from '../../../components/avatar'; | ||||
| import { DisplayName } from '../../../components/display_name'; | ||||
| import { IconButton } from '../../../components/icon_button'; | ||||
| export const ReplyIndicator = () => { | ||||
|   const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to'])); | ||||
|   const status = useSelector(state => state.getIn(['statuses', inReplyToId])); | ||||
|   const account = useSelector(state => state.getIn(['accounts', status?.get('account')])); | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, | ||||
| }); | ||||
| 
 | ||||
| class ReplyIndicator extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     status: ImmutablePropTypes.map, | ||||
|     onCancel: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   handleClick = () => { | ||||
|     this.props.onCancel(); | ||||
|   }; | ||||
| 
 | ||||
|   handleAccountClick = (e) => { | ||||
|     if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { | ||||
|       e.preventDefault(); | ||||
|       this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { status, intl } = this.props; | ||||
| 
 | ||||
|     if (!status) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const content = { __html: status.get('contentHtml') }; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='reply-indicator'> | ||||
|         <div className='reply-indicator__header'> | ||||
|           <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div> | ||||
| 
 | ||||
|           <a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='reply-indicator__display-name'> | ||||
|             <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div> | ||||
|             <DisplayName account={status.get('account')} /> | ||||
|           </a> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} /> | ||||
| 
 | ||||
|         {status.get('media_attachments').size > 0 && ( | ||||
|           <AttachmentList | ||||
|             compact | ||||
|             media={status.get('media_attachments')} | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
|     ); | ||||
|   if (!status) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|   return ( | ||||
|     <div className='reply-indicator'> | ||||
|       <div className='reply-indicator__line' /> | ||||
| 
 | ||||
| export default injectIntl(ReplyIndicator); | ||||
|       <Link to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'> | ||||
|         <Avatar account={account} size={46} /> | ||||
|       </Link> | ||||
| 
 | ||||
|       <div className='reply-indicator__main'> | ||||
|         <Link to={`/@${account.get('acct')}`} className='detailed-status__display-name'> | ||||
|           <DisplayName account={account} /> | ||||
|         </Link> | ||||
| 
 | ||||
|         <EmbeddedStatusContent | ||||
|           className='reply-indicator__content translate' | ||||
|           content={status.get('contentHtml')} | ||||
|           language={status.get('language')} | ||||
|           mentions={status.get('mentions')} | ||||
|         /> | ||||
| 
 | ||||
|         {(status.get('poll') || status.get('media_attachments').size > 0) && ( | ||||
|           <div className='reply-indicator__attachments'> | ||||
|             {status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>} | ||||
|             {status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>} | ||||
|           </div> | ||||
|         )} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -4,12 +4,18 @@ import { PureComponent } from 'react'; | |||
| import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| import { withRouter } from 'react-router-dom'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| 
 | ||||
| import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; | ||||
| import CloseIcon from '@/material-icons/400-24px/close.svg?react'; | ||||
| import SearchIcon from '@/material-icons/400-24px/search.svg?react'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; | ||||
| import { domain, searchEnabled } from 'mastodon/initial_state'; | ||||
| import { HASHTAG_REGEX } from 'mastodon/utils/hashtags'; | ||||
| import { WithRouterPropTypes } from 'mastodon/utils/react_router'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, | ||||
|  | @ -28,13 +34,8 @@ const labelForRecentSearch = search => { | |||
| }; | ||||
| 
 | ||||
| class Search extends PureComponent { | ||||
| 
 | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object.isRequired, | ||||
|     identity: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     identity: identityContextPropShape, | ||||
|     value: PropTypes.string.isRequired, | ||||
|     recent: ImmutablePropTypes.orderedSet, | ||||
|     submitted: PropTypes.bool, | ||||
|  | @ -48,6 +49,7 @@ class Search extends PureComponent { | |||
|     openInRoute: PropTypes.bool, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     singleColumn: PropTypes.bool, | ||||
|     ...WithRouterPropTypes, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -57,14 +59,14 @@ class Search extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   defaultOptions = [ | ||||
|     { label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } }, | ||||
|     { label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } }, | ||||
|     { label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } }, | ||||
|     { label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } }, | ||||
|     { label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } }, | ||||
|     { label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } }, | ||||
|     { label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } }, | ||||
|     { label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, action: e => { e.preventDefault(); this._insertText('in:') } } | ||||
|     { key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } }, | ||||
|     { key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } }, | ||||
|     { key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } }, | ||||
|     { key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } }, | ||||
|     { key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } }, | ||||
|     { key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } }, | ||||
|     { key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } }, | ||||
|     { key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } } | ||||
|   ]; | ||||
| 
 | ||||
|   setRef = c => { | ||||
|  | @ -160,32 +162,29 @@ class Search extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   handleHashtagClick = () => { | ||||
|     const { router } = this.context; | ||||
|     const { value, onClickSearchResult } = this.props; | ||||
|     const { value, onClickSearchResult, history } = this.props; | ||||
| 
 | ||||
|     const query = value.trim().replace(/^#/, ''); | ||||
| 
 | ||||
|     router.history.push(`/tags/${query}`); | ||||
|     history.push(`/tags/${query}`); | ||||
|     onClickSearchResult(query, 'hashtag'); | ||||
|     this._unfocus(); | ||||
|   }; | ||||
| 
 | ||||
|   handleAccountClick = () => { | ||||
|     const { router } = this.context; | ||||
|     const { value, onClickSearchResult } = this.props; | ||||
|     const { value, onClickSearchResult, history } = this.props; | ||||
| 
 | ||||
|     const query = value.trim().replace(/^@/, ''); | ||||
| 
 | ||||
|     router.history.push(`/@${query}`); | ||||
|     history.push(`/@${query}`); | ||||
|     onClickSearchResult(query, 'account'); | ||||
|     this._unfocus(); | ||||
|   }; | ||||
| 
 | ||||
|   handleURLClick = () => { | ||||
|     const { router } = this.context; | ||||
|     const { value, onOpenURL } = this.props; | ||||
|     const { value, onOpenURL, history } = this.props; | ||||
| 
 | ||||
|     onOpenURL(value, router.history); | ||||
|     onOpenURL(value, history); | ||||
|     this._unfocus(); | ||||
|   }; | ||||
| 
 | ||||
|  | @ -198,13 +197,12 @@ class Search extends PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   handleRecentSearchClick = search => { | ||||
|     const { onChange } = this.props; | ||||
|     const { router } = this.context; | ||||
|     const { onChange, history } = this.props; | ||||
| 
 | ||||
|     if (search.get('type') === 'account') { | ||||
|       router.history.push(`/@${search.get('q')}`); | ||||
|       history.push(`/@${search.get('q')}`); | ||||
|     } else if (search.get('type') === 'hashtag') { | ||||
|       router.history.push(`/tags/${search.get('q')}`); | ||||
|       history.push(`/tags/${search.get('q')}`); | ||||
|     } else { | ||||
|       onChange(search.get('q')); | ||||
|       this._submit(search.get('type')); | ||||
|  | @ -236,8 +234,7 @@ class Search extends PureComponent { | |||
|   } | ||||
| 
 | ||||
|   _submit (type) { | ||||
|     const { onSubmit, openInRoute, value, onClickSearchResult } = this.props; | ||||
|     const { router } = this.context; | ||||
|     const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props; | ||||
| 
 | ||||
|     onSubmit(type); | ||||
| 
 | ||||
|  | @ -246,7 +243,7 @@ class Search extends PureComponent { | |||
|     } | ||||
| 
 | ||||
|     if (openInRoute) { | ||||
|       router.history.push('/search'); | ||||
|       history.push('/search'); | ||||
|     } | ||||
| 
 | ||||
|     this._unfocus(); | ||||
|  | @ -262,6 +259,8 @@ class Search extends PureComponent { | |||
|     const { recent } = this.props; | ||||
| 
 | ||||
|     return recent.toArray().map(search => ({ | ||||
|       key: `${search.get('type')}/${search.get('q')}`, | ||||
| 
 | ||||
|       label: labelForRecentSearch(search), | ||||
| 
 | ||||
|       action: () => this.handleRecentSearchClick(search), | ||||
|  | @ -274,7 +273,7 @@ class Search extends PureComponent { | |||
|   } | ||||
| 
 | ||||
|   _calculateOptions (value) { | ||||
|     const { signedIn } = this.context.identity; | ||||
|     const { signedIn } = this.props.identity; | ||||
|     const trimmedValue = value.trim(); | ||||
|     const options = []; | ||||
| 
 | ||||
|  | @ -316,7 +315,7 @@ class Search extends PureComponent { | |||
|   render () { | ||||
|     const { intl, value, submitted, recent } = this.props; | ||||
|     const { expanded, options, selectedOption } = this.state; | ||||
|     const { signedIn } = this.context.identity; | ||||
|     const { signedIn } = this.props.identity; | ||||
| 
 | ||||
|     const hasValue = value.length > 0 || submitted; | ||||
| 
 | ||||
|  | @ -336,8 +335,8 @@ class Search extends PureComponent { | |||
|         /> | ||||
| 
 | ||||
|         <div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}> | ||||
|           <Icon id='search' className={hasValue ? '' : 'active'} /> | ||||
|           <Icon id='times-circle' className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} /> | ||||
|           <Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} /> | ||||
|           <Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} /> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='search__popout'> | ||||
|  | @ -346,10 +345,10 @@ class Search extends PureComponent { | |||
|               <h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4> | ||||
| 
 | ||||
|               <div className='search__popout__menu'> | ||||
|                 {recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => ( | ||||
|                   <button key={label} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}> | ||||
|                 {recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => ( | ||||
|                   <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}> | ||||
|                     <span>{label}</span> | ||||
|                     <button className='icon-button' onMouseDown={forget}><Icon id='times' /></button> | ||||
|                     <button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button> | ||||
|                   </button> | ||||
|                 )) : ( | ||||
|                   <div className='search__popout__menu__message'> | ||||
|  | @ -400,4 +399,4 @@ class Search extends PureComponent { | |||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default injectIntl(Search); | ||||
| export default withRouter(withIdentity(injectIntl(Search))); | ||||
|  |  | |||
|  | @ -1,13 +1,16 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { useCallback } from 'react'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| 
 | ||||
| import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; | ||||
| import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; | ||||
| import TagIcon from '@/material-icons/400-24px/tag.svg?react'; | ||||
| import { expandSearch } from 'mastodon/actions/search'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| import { LoadMore } from 'mastodon/components/load_more'; | ||||
| import { LoadingIndicator } from 'mastodon/components/loading_indicator'; | ||||
| import { SearchSection } from 'mastodon/features/explore/components/search_section'; | ||||
| import { useAppDispatch, useAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; | ||||
| import AccountContainer from '../../../containers/account_container'; | ||||
|  | @ -23,67 +26,68 @@ const withoutLastResult = list => { | |||
|   } | ||||
| }; | ||||
| 
 | ||||
| class SearchResults extends ImmutablePureComponent { | ||||
| export const SearchResults = () => { | ||||
|   const results = useAppSelector((state) => state.getIn(['search', 'results'])); | ||||
|   const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading'])); | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     results: ImmutablePropTypes.map.isRequired, | ||||
|     expandSearch: PropTypes.func.isRequired, | ||||
|     searchTerm: PropTypes.string, | ||||
|   }; | ||||
|   const dispatch = useAppDispatch(); | ||||
| 
 | ||||
|   handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); | ||||
|   const handleLoadMoreAccounts = useCallback(() => { | ||||
|     dispatch(expandSearch('accounts')); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); | ||||
|   const handleLoadMoreStatuses = useCallback(() => { | ||||
|     dispatch(expandSearch('statuses')); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); | ||||
|   const handleLoadMoreHashtags = useCallback(() => { | ||||
|     dispatch(expandSearch('hashtags')); | ||||
|   }, [dispatch]); | ||||
| 
 | ||||
|   render () { | ||||
|     const { results } = this.props; | ||||
|   let accounts, statuses, hashtags; | ||||
| 
 | ||||
|     let accounts, statuses, hashtags; | ||||
| 
 | ||||
|     if (results.get('accounts') && results.get('accounts').size > 0) { | ||||
|       accounts = ( | ||||
|         <SearchSection title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}> | ||||
|           {withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)} | ||||
|           {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} | ||||
|         </SearchSection> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (results.get('hashtags') && results.get('hashtags').size > 0) { | ||||
|       hashtags = ( | ||||
|         <SearchSection title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}> | ||||
|           {withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} | ||||
|           {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} | ||||
|         </SearchSection> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (results.get('statuses') && results.get('statuses').size > 0) { | ||||
|       statuses = ( | ||||
|         <SearchSection title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}> | ||||
|           {withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)} | ||||
|           {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} | ||||
|         </SearchSection> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='search-results'> | ||||
|         <div className='search-results__header'> | ||||
|           <Icon id='search' fixedWidth /> | ||||
|           <FormattedMessage id='explore.search_results' defaultMessage='Search results' /> | ||||
|         </div> | ||||
| 
 | ||||
|         {accounts} | ||||
|         {hashtags} | ||||
|         {statuses} | ||||
|       </div> | ||||
|   if (results.get('accounts') && results.get('accounts').size > 0) { | ||||
|     accounts = ( | ||||
|       <SearchSection title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}> | ||||
|         {withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)} | ||||
|         {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreAccounts} />} | ||||
|       </SearchSection> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|   if (results.get('hashtags') && results.get('hashtags').size > 0) { | ||||
|     hashtags = ( | ||||
|       <SearchSection title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}> | ||||
|         {withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} | ||||
|         {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreHashtags} />} | ||||
|       </SearchSection> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| export default SearchResults; | ||||
|   if (results.get('statuses') && results.get('statuses').size > 0) { | ||||
|     statuses = ( | ||||
|       <SearchSection title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}> | ||||
|         {withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)} | ||||
|         {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreStatuses} />} | ||||
|       </SearchSection> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='search-results'> | ||||
|       {!accounts && !hashtags && !statuses && ( | ||||
|         isLoading ? ( | ||||
|           <LoadingIndicator /> | ||||
|         ) : ( | ||||
|           <div className='empty-column-indicator'> | ||||
|             <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' /> | ||||
|           </div> | ||||
|         ) | ||||
|       )} | ||||
|       {accounts} | ||||
|       {hashtags} | ||||
|       {statuses} | ||||
|     </div> | ||||
|   ); | ||||
| 
 | ||||
| }; | ||||
|  |  | |||
|  | @ -1,70 +0,0 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| 
 | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| 
 | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| 
 | ||||
| import Motion from '../../ui/util/optional_motion'; | ||||
| 
 | ||||
| export default class Upload extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|     onUndo: PropTypes.func.isRequired, | ||||
|     onOpenFocalPoint: PropTypes.func.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   handleUndoClick = e => { | ||||
|     e.stopPropagation(); | ||||
|     this.props.onUndo(this.props.media.get('id')); | ||||
|   }; | ||||
| 
 | ||||
|   handleFocalPointClick = e => { | ||||
|     e.stopPropagation(); | ||||
|     this.props.onOpenFocalPoint(this.props.media.get('id')); | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { media } = this.props; | ||||
| 
 | ||||
|     if (!media) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const focusX = media.getIn(['meta', 'focus', 'x']); | ||||
|     const focusY = media.getIn(['meta', 'focus', 'y']); | ||||
|     const x = ((focusX /  2) + .5) * 100; | ||||
|     const y = ((focusY / -2) + .5) * 100; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-form__upload'> | ||||
|         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> | ||||
|           {({ scale }) => ( | ||||
|             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> | ||||
|               <div className='compose-form__upload__actions'> | ||||
|                 <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> | ||||
|                 <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> | ||||
|               </div> | ||||
| 
 | ||||
|               {(media.get('description') || '').length === 0 && ( | ||||
|                 <div className='compose-form__upload__warning'> | ||||
|                   <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button> | ||||
|                 </div> | ||||
|               )} | ||||
|             </div> | ||||
|           )} | ||||
|         </Motion> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										130
									
								
								app/javascript/mastodon/features/compose/components/upload.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								app/javascript/mastodon/features/compose/components/upload.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,130 @@ | |||
| import { useCallback } from 'react'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| 
 | ||||
| import { useSortable } from '@dnd-kit/sortable'; | ||||
| import { CSS } from '@dnd-kit/utilities'; | ||||
| 
 | ||||
| import CloseIcon from '@/material-icons/400-20px/close.svg?react'; | ||||
| import EditIcon from '@/material-icons/400-24px/edit.svg?react'; | ||||
| import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; | ||||
| import { | ||||
|   undoUploadCompose, | ||||
|   initMediaEditModal, | ||||
| } from 'mastodon/actions/compose'; | ||||
| import { Blurhash } from 'mastodon/components/blurhash'; | ||||
| import { Icon } from 'mastodon/components/icon'; | ||||
| import type { MediaAttachment } from 'mastodon/models/media_attachment'; | ||||
| import { useAppDispatch, useAppSelector } from 'mastodon/store'; | ||||
| 
 | ||||
| export const Upload: React.FC<{ | ||||
|   id: string; | ||||
|   dragging?: boolean; | ||||
|   overlay?: boolean; | ||||
|   tall?: boolean; | ||||
|   wide?: boolean; | ||||
| }> = ({ id, dragging, overlay, tall, wide }) => { | ||||
|   const dispatch = useAppDispatch(); | ||||
|   const media = useAppSelector( | ||||
|     (state) => | ||||
|       state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
 | ||||
|         .get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
 | ||||
|         .find((item: MediaAttachment) => item.get('id') === id) as  // eslint-disable-line @typescript-eslint/no-unsafe-member-access
 | ||||
|         | MediaAttachment | ||||
|         | undefined, | ||||
|   ); | ||||
|   const sensitive = useAppSelector( | ||||
|     (state) => state.compose.get('spoiler') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
 | ||||
|   ); | ||||
| 
 | ||||
|   const handleUndoClick = useCallback(() => { | ||||
|     dispatch(undoUploadCompose(id)); | ||||
|   }, [dispatch, id]); | ||||
| 
 | ||||
|   const handleFocalPointClick = useCallback(() => { | ||||
|     dispatch(initMediaEditModal(id)); | ||||
|   }, [dispatch, id]); | ||||
| 
 | ||||
|   const { attributes, listeners, setNodeRef, transform, transition } = | ||||
|     useSortable({ id }); | ||||
| 
 | ||||
|   if (!media) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const focusX = media.getIn(['meta', 'focus', 'x']) as number; | ||||
|   const focusY = media.getIn(['meta', 'focus', 'y']) as number; | ||||
|   const x = (focusX / 2 + 0.5) * 100; | ||||
|   const y = (focusY / -2 + 0.5) * 100; | ||||
|   const missingDescription = | ||||
|     ((media.get('description') as string | undefined) ?? '').length === 0; | ||||
| 
 | ||||
|   const style = { | ||||
|     transform: CSS.Transform.toString(transform), | ||||
|     transition, | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       className={classNames('compose-form__upload media-gallery__item', { | ||||
|         dragging, | ||||
|         overlay, | ||||
|         'media-gallery__item--tall': tall, | ||||
|         'media-gallery__item--wide': wide, | ||||
|       })} | ||||
|       ref={setNodeRef} | ||||
|       style={style} | ||||
|       {...attributes} | ||||
|       {...listeners} | ||||
|     > | ||||
|       <div | ||||
|         className='compose-form__upload__thumbnail' | ||||
|         style={{ | ||||
|           backgroundImage: !sensitive | ||||
|             ? `url(${media.get('preview_url') as string})` | ||||
|             : undefined, | ||||
|           backgroundPosition: `${x}% ${y}%`, | ||||
|         }} | ||||
|       > | ||||
|         {sensitive && ( | ||||
|           <Blurhash | ||||
|             hash={media.get('blurhash') as string} | ||||
|             className='compose-form__upload__preview' | ||||
|           /> | ||||
|         )} | ||||
| 
 | ||||
|         <div className='compose-form__upload__actions'> | ||||
|           <button | ||||
|             type='button' | ||||
|             className='icon-button compose-form__upload__delete' | ||||
|             onClick={handleUndoClick} | ||||
|           > | ||||
|             <Icon id='close' icon={CloseIcon} /> | ||||
|           </button> | ||||
|           <button | ||||
|             type='button' | ||||
|             className='icon-button' | ||||
|             onClick={handleFocalPointClick} | ||||
|           > | ||||
|             <Icon id='edit' icon={EditIcon} />{' '} | ||||
|             <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /> | ||||
|           </button> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='compose-form__upload__warning'> | ||||
|           <button | ||||
|             type='button' | ||||
|             className={classNames('icon-button', { | ||||
|               active: missingDescription, | ||||
|             })} | ||||
|             onClick={handleFocalPointClick} | ||||
|           > | ||||
|             {missingDescription && <Icon id='warning' icon={WarningIcon} />} ALT | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -6,7 +6,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { IconButton } from '../../../components/icon_button'; | ||||
| import PhotoLibraryIcon from '@/material-icons/400-20px/photo_library.svg?react'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' }, | ||||
|  | @ -29,7 +30,6 @@ class UploadButton extends ImmutablePureComponent { | |||
| 
 | ||||
|   static propTypes = { | ||||
|     disabled: PropTypes.bool, | ||||
|     unavailable: PropTypes.bool, | ||||
|     onSelectFile: PropTypes.func.isRequired, | ||||
|     style: PropTypes.object, | ||||
|     resetFileKey: PropTypes.number, | ||||
|  | @ -52,23 +52,20 @@ class UploadButton extends ImmutablePureComponent { | |||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, resetFileKey, unavailable, disabled, acceptContentTypes } = this.props; | ||||
| 
 | ||||
|     if (unavailable) { | ||||
|       return null; | ||||
|     } | ||||
|     const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; | ||||
| 
 | ||||
|     const message = intl.formatMessage(messages.upload); | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-form__upload-button'> | ||||
|         <IconButton icon='paperclip' title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> | ||||
|         <IconButton icon='paperclip' iconComponent={PhotoLibraryIcon} title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> | ||||
|         <label> | ||||
|           <span style={{ display: 'none' }}>{message}</span> | ||||
|           <input | ||||
|             key={resetFileKey} | ||||
|             ref={this.setRef} | ||||
|             type='file' | ||||
|             name='file-upload-input' | ||||
|             multiple | ||||
|             accept={acceptContentTypes.toArray().join(',')} | ||||
|             onChange={this.handleChange} | ||||
|  |  | |||
|  | @ -1,32 +0,0 @@ | |||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| 
 | ||||
| import SensitiveButtonContainer from '../containers/sensitive_button_container'; | ||||
| import UploadContainer from '../containers/upload_container'; | ||||
| import UploadProgressContainer from '../containers/upload_progress_container'; | ||||
| 
 | ||||
| export default class UploadForm extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     mediaIds: ImmutablePropTypes.list.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { mediaIds } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-form__upload-wrapper'> | ||||
|         <UploadProgressContainer /> | ||||
| 
 | ||||
|         <div className='compose-form__uploads-wrapper'> | ||||
|           {mediaIds.map(id => ( | ||||
|             <UploadContainer id={id} key={id} /> | ||||
|           ))} | ||||
|         </div> | ||||
| 
 | ||||
|         {!mediaIds.isEmpty() && <SensitiveButtonContainer />} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -0,0 +1,185 @@ | |||
| import { useState, useCallback, useMemo } from 'react'; | ||||
| 
 | ||||
| import { useIntl, defineMessages } from 'react-intl'; | ||||
| 
 | ||||
| import type { List } from 'immutable'; | ||||
| 
 | ||||
| import type { | ||||
|   DragStartEvent, | ||||
|   DragEndEvent, | ||||
|   UniqueIdentifier, | ||||
|   Announcements, | ||||
|   ScreenReaderInstructions, | ||||
| } from '@dnd-kit/core'; | ||||
| import { | ||||
|   DndContext, | ||||
|   closestCenter, | ||||
|   KeyboardSensor, | ||||
|   PointerSensor, | ||||
|   useSensor, | ||||
|   useSensors, | ||||
|   DragOverlay, | ||||
| } from '@dnd-kit/core'; | ||||
| import { | ||||
|   SortableContext, | ||||
|   sortableKeyboardCoordinates, | ||||
|   rectSortingStrategy, | ||||
| } from '@dnd-kit/sortable'; | ||||
| 
 | ||||
| import { changeMediaOrder } from 'mastodon/actions/compose'; | ||||
| import type { MediaAttachment } from 'mastodon/models/media_attachment'; | ||||
| import { useAppSelector, useAppDispatch } from 'mastodon/store'; | ||||
| 
 | ||||
| import { Upload } from './upload'; | ||||
| import { UploadProgress } from './upload_progress'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   screenReaderInstructions: { | ||||
|     id: 'upload_form.drag_and_drop.instructions', | ||||
|     defaultMessage: | ||||
|       'To pick up a media attachment, press space or enter. While dragging, use the arrow keys to move the media attachment in any given direction. Press space or enter again to drop the media attachment in its new position, or press escape to cancel.', | ||||
|   }, | ||||
|   onDragStart: { | ||||
|     id: 'upload_form.drag_and_drop.on_drag_start', | ||||
|     defaultMessage: 'Picked up media attachment {item}.', | ||||
|   }, | ||||
|   onDragOver: { | ||||
|     id: 'upload_form.drag_and_drop.on_drag_over', | ||||
|     defaultMessage: 'Media attachment {item} was moved.', | ||||
|   }, | ||||
|   onDragEnd: { | ||||
|     id: 'upload_form.drag_and_drop.on_drag_end', | ||||
|     defaultMessage: 'Media attachment {item} was dropped.', | ||||
|   }, | ||||
|   onDragCancel: { | ||||
|     id: 'upload_form.drag_and_drop.on_drag_cancel', | ||||
|     defaultMessage: | ||||
|       'Dragging was cancelled. Media attachment {item} was dropped.', | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| export const UploadForm: React.FC = () => { | ||||
|   const dispatch = useAppDispatch(); | ||||
|   const intl = useIntl(); | ||||
|   const mediaIds = useAppSelector( | ||||
|     (state) => | ||||
|       state.compose // eslint-disable-line @typescript-eslint/no-unsafe-call
 | ||||
|         .get('media_attachments') // eslint-disable-line @typescript-eslint/no-unsafe-member-access
 | ||||
|         .map((item: MediaAttachment) => item.get('id')) as List<string>, // eslint-disable-line @typescript-eslint/no-unsafe-member-access
 | ||||
|   ); | ||||
|   const active = useAppSelector( | ||||
|     (state) => state.compose.get('is_uploading') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
 | ||||
|   ); | ||||
|   const progress = useAppSelector( | ||||
|     (state) => state.compose.get('progress') as number, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
 | ||||
|   ); | ||||
|   const isProcessing = useAppSelector( | ||||
|     (state) => state.compose.get('is_processing') as boolean, // eslint-disable-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
 | ||||
|   ); | ||||
|   const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null); | ||||
|   const sensors = useSensors( | ||||
|     useSensor(PointerSensor, { | ||||
|       activationConstraint: { | ||||
|         distance: 5, | ||||
|       }, | ||||
|     }), | ||||
|     useSensor(KeyboardSensor, { | ||||
|       coordinateGetter: sortableKeyboardCoordinates, | ||||
|     }), | ||||
|   ); | ||||
| 
 | ||||
|   const handleDragStart = useCallback( | ||||
|     (e: DragStartEvent) => { | ||||
|       const { active } = e; | ||||
| 
 | ||||
|       setActiveId(active.id); | ||||
|     }, | ||||
|     [setActiveId], | ||||
|   ); | ||||
| 
 | ||||
|   const handleDragEnd = useCallback( | ||||
|     (e: DragEndEvent) => { | ||||
|       const { active, over } = e; | ||||
| 
 | ||||
|       if (over && active.id !== over.id) { | ||||
|         dispatch(changeMediaOrder(active.id, over.id)); | ||||
|       } | ||||
| 
 | ||||
|       setActiveId(null); | ||||
|     }, | ||||
|     [dispatch, setActiveId], | ||||
|   ); | ||||
| 
 | ||||
|   const accessibility: { | ||||
|     screenReaderInstructions: ScreenReaderInstructions; | ||||
|     announcements: Announcements; | ||||
|   } = useMemo( | ||||
|     () => ({ | ||||
|       screenReaderInstructions: { | ||||
|         draggable: intl.formatMessage(messages.screenReaderInstructions), | ||||
|       }, | ||||
| 
 | ||||
|       announcements: { | ||||
|         onDragStart({ active }) { | ||||
|           return intl.formatMessage(messages.onDragStart, { item: active.id }); | ||||
|         }, | ||||
| 
 | ||||
|         onDragOver({ active }) { | ||||
|           return intl.formatMessage(messages.onDragOver, { item: active.id }); | ||||
|         }, | ||||
| 
 | ||||
|         onDragEnd({ active }) { | ||||
|           return intl.formatMessage(messages.onDragEnd, { item: active.id }); | ||||
|         }, | ||||
| 
 | ||||
|         onDragCancel({ active }) { | ||||
|           return intl.formatMessage(messages.onDragCancel, { item: active.id }); | ||||
|         }, | ||||
|       }, | ||||
|     }), | ||||
|     [intl], | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <UploadProgress | ||||
|         active={active} | ||||
|         progress={progress} | ||||
|         isProcessing={isProcessing} | ||||
|       /> | ||||
| 
 | ||||
|       {mediaIds.size > 0 && ( | ||||
|         <div | ||||
|           className={`compose-form__uploads media-gallery media-gallery--layout-${mediaIds.size}`} | ||||
|         > | ||||
|           <DndContext | ||||
|             sensors={sensors} | ||||
|             collisionDetection={closestCenter} | ||||
|             onDragStart={handleDragStart} | ||||
|             onDragEnd={handleDragEnd} | ||||
|             accessibility={accessibility} | ||||
|           > | ||||
|             <SortableContext | ||||
|               items={mediaIds.toArray()} | ||||
|               strategy={rectSortingStrategy} | ||||
|             > | ||||
|               {mediaIds.map((id, idx) => ( | ||||
|                 <Upload | ||||
|                   key={id} | ||||
|                   id={id} | ||||
|                   dragging={id === activeId} | ||||
|                   tall={mediaIds.size < 3 || (mediaIds.size === 3 && idx === 0)} | ||||
|                   wide={mediaIds.size === 1} | ||||
|                 /> | ||||
|               ))} | ||||
|             </SortableContext> | ||||
| 
 | ||||
|             <DragOverlay> | ||||
|               {activeId ? <Upload id={activeId as string} overlay /> : null} | ||||
|             </DragOverlay> | ||||
|           </DndContext> | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | @ -1,56 +1,48 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| 
 | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| 
 | ||||
| import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| 
 | ||||
| import Motion from '../../ui/util/optional_motion'; | ||||
| 
 | ||||
| export default class UploadProgress extends PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     active: PropTypes.bool, | ||||
|     progress: PropTypes.number, | ||||
|     isProcessing: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { active, progress, isProcessing } = this.props; | ||||
| 
 | ||||
|     if (!active) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     let message; | ||||
| 
 | ||||
|     if (isProcessing) { | ||||
|       message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />; | ||||
|     } else { | ||||
|       message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='upload-progress'> | ||||
|         <div className='upload-progress__icon'> | ||||
|           <Icon id='upload' /> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='upload-progress__message'> | ||||
|           {message} | ||||
| 
 | ||||
|           <div className='upload-progress__backdrop'> | ||||
|             <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> | ||||
|               {({ width }) => | ||||
|                 <div className='upload-progress__tracker' style={{ width: `${width}%` }} /> | ||||
|               } | ||||
|             </Motion> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
| export const UploadProgress = ({ active, progress, isProcessing }) => { | ||||
|   if (!active) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|   let message; | ||||
| 
 | ||||
|   if (isProcessing) { | ||||
|     message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />; | ||||
|   } else { | ||||
|     message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className='upload-progress'> | ||||
|       <Icon id='upload' icon={UploadFileIcon} /> | ||||
| 
 | ||||
|       <div className='upload-progress__message'> | ||||
|         {message} | ||||
| 
 | ||||
|         <div className='upload-progress__backdrop'> | ||||
|           <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> | ||||
|             {({ width }) => | ||||
|               <div className='upload-progress__tracker' style={{ width: `${width}%` }} /> | ||||
|             } | ||||
|           </Motion> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| UploadProgress.propTypes = { | ||||
|   active: PropTypes.bool, | ||||
|   progress: PropTypes.number, | ||||
|   isProcessing: PropTypes.bool, | ||||
| }; | ||||
|  |  | |||
|  | @ -28,6 +28,7 @@ const mapStateToProps = state => ({ | |||
|   anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, | ||||
|   isInReply: state.getIn(['compose', 'in_reply_to']) !== null, | ||||
|   lang: state.getIn(['compose', 'language']), | ||||
|   maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 640), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = (dispatch) => ({ | ||||
|  | @ -36,8 +37,8 @@ const mapDispatchToProps = (dispatch) => ({ | |||
|     dispatch(changeCompose(text)); | ||||
|   }, | ||||
| 
 | ||||
|   onSubmit (router) { | ||||
|     dispatch(submitCompose(router)); | ||||
|   onSubmit () { | ||||
|     dispatch(submitCompose()); | ||||
|   }, | ||||
| 
 | ||||
|   onClearSuggestions () { | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import { createSelector } from '@reduxjs/toolkit'; | ||||
| import { Map as ImmutableMap } from 'immutable'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { createSelector } from 'reselect'; | ||||
| 
 | ||||
| 
 | ||||
| import { useEmoji } from '../../../actions/emojis'; | ||||
| import { changeSetting } from '../../../actions/settings'; | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| import { createSelector } from '@reduxjs/toolkit'; | ||||
| import { Map as ImmutableMap } from 'immutable'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { createSelector } from 'reselect'; | ||||
| 
 | ||||
| 
 | ||||
| import { changeComposeLanguage } from 'mastodon/actions/compose'; | ||||
| import { useLanguage } from 'mastodon/actions/languages'; | ||||
| 
 | ||||
| import LanguageDropdown from '../components/language_dropdown'; | ||||
| 
 | ||||
|  | @ -27,11 +27,6 @@ const mapDispatchToProps = dispatch => ({ | |||
|     dispatch(changeComposeLanguage(value)); | ||||
|   }, | ||||
| 
 | ||||
|   onClose (value) { | ||||
|     // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
 | ||||
|     dispatch(useLanguage(value)); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown); | ||||
|  |  | |||
|  | @ -1,36 +0,0 @@ | |||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| 
 | ||||
| import { connect }   from 'react-redux'; | ||||
| 
 | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import { logOut } from 'mastodon/utils/log_out'; | ||||
| 
 | ||||
| import { me } from '../../../initial_state'; | ||||
| import NavigationBar from '../components/navigation_bar'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, | ||||
|   logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = state => { | ||||
|   return { | ||||
|     account: state.getIn(['accounts', me]), | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||
|   onLogout () { | ||||
|     dispatch(openModal({ | ||||
|       modalType: 'CONFIRM', | ||||
|       modalProps: { | ||||
|         message: intl.formatMessage(messages.logoutMessage), | ||||
|         confirm: intl.formatMessage(messages.logoutConfirm), | ||||
|         closeWhenConfirm: false, | ||||
|         onConfirm: () => logOut(), | ||||
|       }, | ||||
|     })); | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar)); | ||||
|  | @ -4,7 +4,7 @@ import { addPoll, removePoll } from '../../../actions/compose'; | |||
| import PollButton from '../components/poll_button'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   unavailable: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0), | ||||
|   disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0), | ||||
|   active: state.getIn(['compose', 'poll']) !== null, | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,53 +0,0 @@ | |||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { | ||||
|   addPollOption, | ||||
|   removePollOption, | ||||
|   changePollOption, | ||||
|   changePollSettings, | ||||
|   clearComposeSuggestions, | ||||
|   fetchComposeSuggestions, | ||||
|   selectComposeSuggestion, | ||||
| } from '../../../actions/compose'; | ||||
| import PollForm from '../components/poll_form'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   suggestions: state.getIn(['compose', 'suggestions']), | ||||
|   options: state.getIn(['compose', 'poll', 'options']), | ||||
|   lang: state.getIn(['compose', 'language']), | ||||
|   expiresIn: state.getIn(['compose', 'poll', 'expires_in']), | ||||
|   isMultiple: state.getIn(['compose', 'poll', 'multiple']), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|   onAddOption(title) { | ||||
|     dispatch(addPollOption(title)); | ||||
|   }, | ||||
| 
 | ||||
|   onRemoveOption(index) { | ||||
|     dispatch(removePollOption(index)); | ||||
|   }, | ||||
| 
 | ||||
|   onChangeOption(index, title) { | ||||
|     dispatch(changePollOption(index, title)); | ||||
|   }, | ||||
| 
 | ||||
|   onChangeSettings(expiresIn, isMultiple) { | ||||
|     dispatch(changePollSettings(expiresIn, isMultiple)); | ||||
|   }, | ||||
| 
 | ||||
|   onClearSuggestions () { | ||||
|     dispatch(clearComposeSuggestions()); | ||||
|   }, | ||||
| 
 | ||||
|   onFetchSuggestions (token) { | ||||
|     dispatch(fetchComposeSuggestions(token)); | ||||
|   }, | ||||
| 
 | ||||
|   onSuggestionSelected (position, token, accountId, path) { | ||||
|     dispatch(selectComposeSuggestion(position, token, accountId, path)); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(PollForm); | ||||
|  | @ -1,36 +0,0 @@ | |||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { cancelReplyCompose } from '../../../actions/compose'; | ||||
| import { makeGetStatus } from '../../../selectors'; | ||||
| import ReplyIndicator from '../components/reply_indicator'; | ||||
| 
 | ||||
| const makeMapStateToProps = () => { | ||||
|   const getStatus = makeGetStatus(); | ||||
| 
 | ||||
|   const mapStateToProps = state => { | ||||
|     let statusId = state.getIn(['compose', 'id'], null); | ||||
|     let editing  = true; | ||||
| 
 | ||||
|     if (statusId === null) { | ||||
|       statusId = state.getIn(['compose', 'in_reply_to']); | ||||
|       editing  = false; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       status: getStatus(state, { id: statusId }), | ||||
|       editing, | ||||
|     }; | ||||
|   }; | ||||
| 
 | ||||
|   return mapStateToProps; | ||||
| }; | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
| 
 | ||||
|   onCancel () { | ||||
|     dispatch(cancelReplyCompose()); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); | ||||
|  | @ -1,3 +1,4 @@ | |||
| import { createSelector } from '@reduxjs/toolkit'; | ||||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { | ||||
|  | @ -12,10 +13,15 @@ import { | |||
| 
 | ||||
| import Search from '../components/search'; | ||||
| 
 | ||||
| const getRecentSearches = createSelector( | ||||
|   state => state.getIn(['search', 'recent']), | ||||
|   recent => recent.reverse(), | ||||
| ); | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   value: state.getIn(['search', 'value']), | ||||
|   submitted: state.getIn(['search', 'submitted']), | ||||
|   recent: state.getIn(['search', 'recent']).reverse(), | ||||
|   recent: getRecentSearches(state), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|  |  | |||
|  | @ -1,20 +0,0 @@ | |||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { expandSearch } from 'mastodon/actions/search'; | ||||
| import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions'; | ||||
| 
 | ||||
| import SearchResults from '../components/search_results'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   results: state.getIn(['search', 'results']), | ||||
|   suggestions: state.getIn(['suggestions', 'items']), | ||||
|   searchTerm: state.getIn(['search', 'searchTerm']), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|   fetchSuggestions: () => dispatch(fetchSuggestions()), | ||||
|   expandSearch: type => dispatch(expandSearch(type)), | ||||
|   dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))), | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); | ||||
|  | @ -1,73 +0,0 @@ | |||
| import PropTypes from 'prop-types'; | ||||
| import { PureComponent } from 'react'; | ||||
| 
 | ||||
| import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; | ||||
| 
 | ||||
| import classNames from 'classnames'; | ||||
| 
 | ||||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { changeComposeSensitivity } from 'mastodon/actions/compose'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   marked: { | ||||
|     id: 'compose_form.sensitive.marked', | ||||
|     defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}', | ||||
|   }, | ||||
|   unmarked: { | ||||
|     id: 'compose_form.sensitive.unmarked', | ||||
|     defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}', | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   active: state.getIn(['compose', 'sensitive']), | ||||
|   disabled: state.getIn(['compose', 'spoiler']), | ||||
|   mediaCount: state.getIn(['compose', 'media_attachments']).size, | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
| 
 | ||||
|   onClick () { | ||||
|     dispatch(changeComposeSensitivity()); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| class SensitiveButton extends PureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     active: PropTypes.bool, | ||||
|     disabled: PropTypes.bool, | ||||
|     mediaCount: PropTypes.number, | ||||
|     onClick: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   render () { | ||||
|     const { active, disabled, mediaCount, onClick, intl } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='compose-form__sensitive-button'> | ||||
|         <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}> | ||||
|           <input | ||||
|             name='mark-sensitive' | ||||
|             type='checkbox' | ||||
|             checked={active} | ||||
|             onChange={onClick} | ||||
|             disabled={disabled} | ||||
|           /> | ||||
| 
 | ||||
|           <FormattedMessage | ||||
|             id='compose_form.sensitive.hide' | ||||
|             defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}' | ||||
|             values={{ count: mediaCount }} | ||||
|           /> | ||||
|         </label> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); | ||||
|  | @ -2,8 +2,10 @@ import { injectIntl, defineMessages } from 'react-intl'; | |||
| 
 | ||||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import WarningIcon from '@/material-icons/400-20px/warning.svg?react'; | ||||
| import { IconButton } from 'mastodon/components/icon_button'; | ||||
| 
 | ||||
| import { changeComposeSpoilerness } from '../../../actions/compose'; | ||||
| import TextIconButton from '../components/text_icon_button'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' }, | ||||
|  | @ -11,10 +13,12 @@ const messages = defineMessages({ | |||
| }); | ||||
| 
 | ||||
| const mapStateToProps = (state, { intl }) => ({ | ||||
|   label: 'CW', | ||||
|   iconComponent: WarningIcon, | ||||
|   title: intl.formatMessage(state.getIn(['compose', 'spoiler']) ? messages.marked : messages.unmarked), | ||||
|   active: state.getIn(['compose', 'spoiler']), | ||||
|   ariaControls: 'cw-spoiler-input', | ||||
|   size: 18, | ||||
|   inverted: true, | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|  | @ -25,4 +29,4 @@ const mapDispatchToProps = dispatch => ({ | |||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); | ||||
| export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton)); | ||||
|  |  | |||
|  | @ -3,15 +3,24 @@ import { connect } from 'react-redux'; | |||
| import { uploadCompose } from '../../../actions/compose'; | ||||
| import UploadButton from '../components/upload_button'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))), | ||||
|   unavailable: state.getIn(['compose', 'poll']) !== null, | ||||
|   resetFileKey: state.getIn(['compose', 'resetFileKey']), | ||||
| }); | ||||
| const mapStateToProps = state => { | ||||
|   const isPoll = state.getIn(['compose', 'poll']) !== null; | ||||
|   const isUploading = state.getIn(['compose', 'is_uploading']); | ||||
|   const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0; | ||||
|   const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0; | ||||
|   const attachmentsSize = readyAttachmentsSize + pendingAttachmentsSize; | ||||
|   const isOverLimit = attachmentsSize > state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments'])-1; | ||||
|   const hasVideoOrAudio = state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type'))); | ||||
| 
 | ||||
|   return { | ||||
|     disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio, | ||||
|     resetFileKey: state.getIn(['compose', 'resetFileKey']), | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
| 
 | ||||
|   onSelectFile (files) { | ||||
|   onSelectFile(files) { | ||||
|     dispatch(uploadCompose(files)); | ||||
|   }, | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,26 +0,0 @@ | |||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import { undoUploadCompose, initMediaEditModal, submitCompose } from '../../../actions/compose'; | ||||
| import Upload from '../components/upload'; | ||||
| 
 | ||||
| const mapStateToProps = (state, { id }) => ({ | ||||
|   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
| 
 | ||||
|   onUndo: id => { | ||||
|     dispatch(undoUploadCompose(id)); | ||||
|   }, | ||||
| 
 | ||||
|   onOpenFocalPoint: id => { | ||||
|     dispatch(initMediaEditModal(id)); | ||||
|   }, | ||||
| 
 | ||||
|   onSubmit (router) { | ||||
|     dispatch(submitCompose(router)); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps, mapDispatchToProps)(Upload); | ||||
|  | @ -1,9 +0,0 @@ | |||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import UploadForm from '../components/upload_form'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps)(UploadForm); | ||||
|  | @ -1,11 +0,0 @@ | |||
| import { connect } from 'react-redux'; | ||||
| 
 | ||||
| import UploadProgress from '../components/upload_progress'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   active: state.getIn(['compose', 'is_uploading']), | ||||
|   progress: state.getIn(['compose', 'progress']), | ||||
|   isProcessing: state.getIn(['compose', 'is_processing']), | ||||
| }); | ||||
| 
 | ||||
| export default connect(mapStateToProps)(UploadProgress); | ||||
|  | @ -11,10 +11,16 @@ import { connect } from 'react-redux'; | |||
| 
 | ||||
| import spring from 'react-motion/lib/spring'; | ||||
| 
 | ||||
| import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; | ||||
| import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; | ||||
| import LogoutIcon from '@/material-icons/400-24px/logout.svg?react'; | ||||
| import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; | ||||
| import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; | ||||
| import PublicIcon from '@/material-icons/400-24px/public.svg?react'; | ||||
| import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| import Column from 'mastodon/components/column'; | ||||
| import { Icon }  from 'mastodon/components/icon'; | ||||
| import { logOut } from 'mastodon/utils/log_out'; | ||||
| 
 | ||||
| import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; | ||||
| import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose'; | ||||
|  | @ -22,10 +28,9 @@ import { mascot } from '../../initial_state'; | |||
| import { isMobile } from '../../is_mobile'; | ||||
| import Motion from '../ui/util/optional_motion'; | ||||
| 
 | ||||
| import { SearchResults } from './components/search_results'; | ||||
| import ComposeFormContainer from './containers/compose_form_container'; | ||||
| import NavigationContainer from './containers/navigation_container'; | ||||
| import SearchContainer from './containers/search_container'; | ||||
| import SearchResultsContainer from './containers/search_results_container'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | ||||
|  | @ -36,8 +41,6 @@ const messages = defineMessages({ | |||
|   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, | ||||
|   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, | ||||
|   compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, | ||||
|   logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, | ||||
|   logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = (state, ownProps) => ({ | ||||
|  | @ -66,20 +69,12 @@ class Compose extends PureComponent { | |||
|   } | ||||
| 
 | ||||
|   handleLogoutClick = e => { | ||||
|     const { dispatch, intl } = this.props; | ||||
|     const { dispatch } = this.props; | ||||
| 
 | ||||
|     e.preventDefault(); | ||||
|     e.stopPropagation(); | ||||
| 
 | ||||
|     dispatch(openModal({ | ||||
|       modalType: 'CONFIRM', | ||||
|       modalProps: { | ||||
|         message: intl.formatMessage(messages.logoutMessage), | ||||
|         confirm: intl.formatMessage(messages.logoutConfirm), | ||||
|         closeWhenConfirm: false, | ||||
|         onConfirm: () => logOut(), | ||||
|       }, | ||||
|     })); | ||||
|     dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' })); | ||||
| 
 | ||||
|     return false; | ||||
|   }; | ||||
|  | @ -101,29 +96,27 @@ class Compose extends PureComponent { | |||
|       return ( | ||||
|         <div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}> | ||||
|           <nav className='drawer__header'> | ||||
|             <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' fixedWidth /></Link> | ||||
|             <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' icon={MenuIcon} /></Link> | ||||
|             {!columns.some(column => column.get('id') === 'HOME') && ( | ||||
|               <Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' fixedWidth /></Link> | ||||
|               <Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' icon={HomeIcon} /></Link> | ||||
|             )} | ||||
|             {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && ( | ||||
|               <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' fixedWidth /></Link> | ||||
|               <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><Icon id='bell' icon={NotificationsIcon} /></Link> | ||||
|             )} | ||||
|             {!columns.some(column => column.get('id') === 'COMMUNITY') && ( | ||||
|               <Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' fixedWidth /></Link> | ||||
|               <Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' icon={PeopleIcon} /></Link> | ||||
|             )} | ||||
|             {!columns.some(column => column.get('id') === 'PUBLIC') && ( | ||||
|               <Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link> | ||||
|               <Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' icon={PublicIcon} /></Link> | ||||
|             )} | ||||
|             <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a> | ||||
|             <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a> | ||||
|             <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' icon={SettingsIcon} /></a> | ||||
|             <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a> | ||||
|           </nav> | ||||
| 
 | ||||
|           {multiColumn && <SearchContainer /> } | ||||
| 
 | ||||
|           <div className='drawer__pager'> | ||||
|             <div className='drawer__inner' onFocus={this.onFocus}> | ||||
|               <NavigationContainer onClose={this.onBlur} /> | ||||
| 
 | ||||
|               <ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} /> | ||||
| 
 | ||||
|               <div className='drawer__inner__mastodon'> | ||||
|  | @ -134,7 +127,7 @@ class Compose extends PureComponent { | |||
|             <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> | ||||
|               {({ x }) => ( | ||||
|                 <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> | ||||
|                   <SearchResultsContainer /> | ||||
|                   <SearchResults /> | ||||
|                 </div> | ||||
|               )} | ||||
|             </Motion> | ||||
|  | @ -145,7 +138,6 @@ class Compose extends PureComponent { | |||
| 
 | ||||
|     return ( | ||||
|       <Column onFocus={this.onFocus}> | ||||
|         <NavigationContainer onClose={this.onBlur} /> | ||||
|         <ComposeFormContainer /> | ||||
| 
 | ||||
|         <Helmet> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue